diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 65ac3a9..578c27f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/actions-rs/grcov.yml b/.github/actions-rs/grcov.yml new file mode 100644 index 0000000..86c7afd --- /dev/null +++ b/.github/actions-rs/grcov.yml @@ -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 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b79ee91..f491d62 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 048a1f3..a80f0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` + - 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 ``, the file when selected will have a `*` prepended to its name + - Select all files in the current directory with `` + - 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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a1d07c..5db7290 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 25a9548..b351f1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 139d4e4..3c6ee2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index ff1acdf..bb4e529 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,28 @@ -# TermSCP +# 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.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) +

~ A feature rich terminal file transfer ~

+

+ Website + ยท + Installation + ยท + User manual +

+ +

Developed by Christian Visintin

+

Current version: 0.5.0 (23/05/2021)

--- -- [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 -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 +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, diff --git a/docs/man.md b/docs/man.md index 96d758c..aa49c00 100644 --- a/docs/man.md +++ b/docs/man.md @@ -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 ` 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 `` to move the remote explorer panel and `` to move back to the local explorer panel. Whenever you are in the find results panel, you need to press `` 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 | `` | Go to supplied path | Go to | | `` | Show help | Help | | `` | Show info about selected file or directory | Info | -| `` | Reload current directory's content | List | +| `` | Reload current directory's content / Clear selection | List | +| `` | Select a file | Mark | | `` | Create new file with provided name | New | -| `` | Edit file; see [Text editor](#text-editor-) | Open | -| `` | Quit TermSCP | Quit | +| `` | Edit file; see Text editor | Open | +| `` | Quit termscp | Quit | | `` | Rename file | Rename | | `` | Save file as... | Save | | `` | Go to parent directory | Upper | | `` | Execute a command | eXecute | +| `` | Toggle synchronized browsing | sYnc | | `` | Delete file | | +| `` | Select all files | | | `` | Abort file transfer process | | +### Work on multiple files ๐Ÿฅท + +You can opt to work on multiple files, selecting them pressing ``, in order to select the current file, or pressing ``, 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 ``; 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 `` to navigate to the bookmarks list and load the bookmark parameters into the form pressing ``. -![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. diff --git a/install.sh b/install.sh index 3dd8cd9..62e434a 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/src/activity_manager.rs b/src/activity_manager.rs index 133481e..98585c1 100644 --- a/src/activity_manager.rs +++ b/src/activity_manager.rs @@ -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, 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 { // 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, Option) = 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 { + 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 { + 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; // 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 diff --git a/src/bookmarks/mod.rs b/src/bookmarks/mod.rs index 77c0c78..58a29cf 100644 --- a/src/bookmarks/mod.rs +++ b/src/bookmarks/mod.rs @@ -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() { diff --git a/src/bookmarks/serializer.rs b/src/bookmarks/serializer.rs index bfa4e8d..6d7d597 100644 --- a/src/bookmarks/serializer.rs +++ b/src/bookmarks/serializer.rs @@ -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}; diff --git a/src/config/mod.rs b/src/config/mod.rs index f3c0a57..4153bc2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -60,7 +60,8 @@ pub struct UserInterfaceConfig { pub show_hidden_files: bool, pub check_for_updates: Option, // @! Since 0.3.3 pub group_dirs: Option, - pub file_fmt: Option, + pub file_fmt: Option, // Refers to local host (for backward compatibility) + pub remote_file_fmt: Option, // @! Since 0.5.0 } #[derive(Deserialize, Serialize, std::fmt::Debug)] @@ -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] diff --git a/src/config/serializer.rs b/src/config/serializer.rs index a4ba940..2375243 100644 --- a/src/config/serializer.rs +++ b/src/config/serializer.rs @@ -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" diff --git a/src/filetransfer/ftp_transfer.rs b/src/filetransfer/ftp_transfer.rs index 1a8bab3..4b69880 100644 --- a/src/filetransfer/ftp_transfer.rs +++ b/src/filetransfer/ftp_transfer.rs @@ -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> = match symlink_path { - None => None, - Some(p) => Some(Box::new(match p.to_string_lossy().ends_with('/') { + let symlink: Option> = 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, ) -> Result, 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 { + 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 { 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, 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 = 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, 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, 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) -> 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) -> 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] diff --git a/src/filetransfer/mod.rs b/src/filetransfer/mod.rs index dbc41c9..fff23c4 100644 --- a/src/filetransfer/mod.rs +++ b/src/filetransfer/mod.rs @@ -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, } +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 { 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); } } diff --git a/src/filetransfer/scp_transfer.rs b/src/filetransfer/scp_transfer.rs index 5de40a3..df17b47 100644 --- a/src/filetransfer/scp_transfer.rs +++ b/src/filetransfer/scp_transfer.rs @@ -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 { 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, ) -> Result, FileTransferError> { // Setup tcp stream + info!("Connecting to {}:{}", address, port); let socket_addresses: Vec = match format!("{}:{}", address, port).to_socket_addrs() { Ok(s) => s.collect(), @@ -325,8 +344,10 @@ impl FileTransfer for ScpFileTransfer { let mut tcp: Option = 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 = 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 { + 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, 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() { diff --git a/src/filetransfer/sftp_transfer.rs b/src/filetransfer/sftp_transfer.rs index 2ad5947..d63f1c4 100644 --- a/src/filetransfer/sftp_transfer.rs +++ b/src/filetransfer/sftp_transfer.rs @@ -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, ) -> Result, FileTransferError> { // Setup tcp stream + info!("Connecting to {}:{}", address, port); let socket_addresses: Vec = match format!("{}:{}", address, port).to_socket_addrs() { Ok(s) => s.collect(), @@ -267,6 +284,7 @@ impl FileTransfer for SftpFileTransfer { let mut tcp: Option = 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 { + 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 = 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 { + 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()); diff --git a/src/fs/explorer/builder.rs b/src/fs/explorer/builder.rs index d088d23..9ba2dce 100644 --- a/src/fs/explorer/builder.rs +++ b/src/fs/explorer/builder.rs @@ -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(); diff --git a/src/fs/explorer/formatter.rs b/src/fs/explorer/formatter.rs index 6294c6c..68a5c0a 100644 --- a/src/fs/explorer/formatter.rs +++ b/src/fs/explorer/formatter.rs @@ -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; diff --git a/src/fs/explorer/mod.rs b/src/fs/explorer/mod.rs index afc5f8c..ec90def 100644 --- a/src/fs/explorer/mod.rs +++ b/src/fs/explorer/mod.rs @@ -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}; diff --git a/src/fs/mod.rs b/src/fs/mod.rs index e82e91d..84ca480 100644 --- a/src/fs/mod.rs +++ b/src/fs/mod.rs @@ -232,6 +232,7 @@ impl FsEntry { mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn test_fs_fsentry_dir() { diff --git a/src/host/mod.rs b/src/host/mod.rs index 32992f6..f3215bc 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -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, @@ -125,12 +125,17 @@ impl Localhost { /// /// Instantiates a new Localhost struct pub fn new(wrkdir: PathBuf) -> Result { + 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 { 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 { + 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 { 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 { 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 { 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, HostError> { + info!("Reading directory {}", dir.display()); match std::fs::read_dir(dir) { Ok(e) => { let mut fs_entries: Vec = 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; diff --git a/src/lib.rs b/src/lib.rs index 009b403..3847ecd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index d9dc761..83e44d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 "); + println!("Please, consider supporting the author ") } fn main() { @@ -78,15 +82,12 @@ fn main() { let mut remote_wrkdir: Option = None; let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; // Default protocol let mut ticks: Duration = Duration::from_millis(10); + let mut log_enabled: bool = true; //Process options let mut opts = Options::new(); - opts.optopt( - "P", - "password", - "Provide password from CLI (use at your own risk)", - "", - ); + opts.optopt("P", "password", "Provide password from CLI", ""); opts.optopt("T", "ticks", "Set UI ticks; default 10ms", ""); + opts.optflag("q", "quiet", "Disable logging"); opts.optflag("v", "version", ""); opts.optflag("h", "help", "Print this menu"); let matches = match opts.parse(&args[1..]) { @@ -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); } diff --git a/src/system/bookmarks_client.rs b/src/system/bookmarks_client.rs index 873ae9a..fdff0c9 100644 --- a/src/system/bookmarks_client.rs +++ b/src/system/bookmarks_client.rs @@ -68,9 +68,11 @@ impl BookmarksClient { ) -> Result { // 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, &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)> { 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, ) { 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; diff --git a/src/system/config_client.rs b/src/system/config_client.rs index 57f47fa..b0919b8 100644 --- a/src/system/config_client.rs +++ b/src/system/config_client.rs @@ -58,6 +58,11 @@ impl ConfigClient { pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result { // 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 { + /// Get current file fmt for local host + pub fn get_local_file_fmt(&self) -> Option { 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 { + 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] diff --git a/src/system/environment.rs b/src/system/environment.rs index 44d2b59..c17cc99 100644 --- a/src/system/environment.rs +++ b/src/system/environment.rs @@ -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"), + ); + } } diff --git a/src/system/keys/filestorage.rs b/src/system/keys/filestorage.rs index da2e046..074ffa3 100644 --- a/src/system/keys/filestorage.rs +++ b/src/system/keys/filestorage.rs @@ -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/")); diff --git a/src/system/keys/keyringstorage.rs b/src/system/keys/keyringstorage.rs index 3e0c46e..5fae681 100644 --- a/src/system/keys/keyringstorage.rs +++ b/src/system/keys/keyringstorage.rs @@ -104,6 +104,7 @@ mod tests { extern crate whoami; use super::*; + use pretty_assertions::assert_eq; use whoami::username; #[test] diff --git a/src/system/keys/mod.rs b/src/system/keys/mod.rs index c15510b..4933a8e 100644 --- a/src/system/keys/mod.rs +++ b/src/system/keys/mod.rs @@ -78,6 +78,8 @@ mod tests { use super::*; + use pretty_assertions::assert_eq; + #[test] fn test_system_keys_mod_errors() { assert_eq!( diff --git a/src/system/logging.rs b/src/system/logging.rs new file mode 100644 index 0000000..bea7d7d --- /dev/null +++ b/src/system/logging.rs @@ -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()); + } +} diff --git a/src/system/mod.rs b/src/system/mod.rs index f48b603..7ff9694 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -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; diff --git a/src/system/sshkey_storage.rs b/src/system/sshkey_storage.rs index d65b848..ea6cfb4 100644 --- a/src/system/sshkey_storage.rs +++ b/src/system/sshkey_storage.rs @@ -42,6 +42,7 @@ impl SshKeyStorage { pub fn storage_from_config(cfg_client: &ConfigClient) -> Self { let mut hosts: HashMap = 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] diff --git a/src/ui/activities/auth_activity/bookmarks.rs b/src/ui/activities/auth/bookmarks.rs similarity index 87% rename from src/ui/activities/auth_activity/bookmarks.rs rename to src/ui/activities/auth/bookmarks.rs index 6e131d7..7bf4534 100644 --- a/src/ui/activities/auth_activity/bookmarks.rs +++ b/src/ui/activities/auth/bookmarks.rs @@ -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 = 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, ) { // 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); } } diff --git a/src/ui/activities/auth/misc.rs b/src/ui/activities/auth/misc.rs new file mode 100644 index 0000000..acda009 --- /dev/null +++ b/src/ui/activities/auth/misc.rs @@ -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 + } +} diff --git a/src/ui/activities/auth_activity/mod.rs b/src/ui/activities/auth/mod.rs similarity index 88% rename from src/ui/activities/auth_activity/mod.rs rename to src/ui/activities/auth/mod.rs index f190e25..0cd7653 100644 --- a/src/ui/activities/auth_activity/mod.rs +++ b/src/ui/activities/auth/mod.rs @@ -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 = 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 { // 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() { diff --git a/src/ui/activities/auth_activity/update.rs b/src/ui/activities/auth/update.rs similarity index 81% rename from src/ui/activities/auth_activity/update.rs rename to src/ui/activities/auth/update.rs index bc789d3..ae9a153 100644 --- a/src/ui/activities/auth_activity/update.rs +++ b/src/ui/activities/auth/update.rs @@ -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)) + } + } + } // 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 { } // 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) + } + } + } } diff --git a/src/ui/activities/auth_activity/view.rs b/src/ui/activities/auth/view.rs similarity index 63% rename from src/ui/activities/auth_activity/view.rs rename to src/ui/activities/auth/view.rs index 18a7065..363801f 100644 --- a/src/ui/activities/auth_activity/view.rs +++ b/src/ui/activities/auth/view.rs @@ -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("") - .bold() - .with_foreground(Color::Cyan) - .build(), - TextSpanBuilder::new(" to show keybindings; ") - .bold() - .build(), - TextSpanBuilder::new("") - .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("") + .bold() + .with_foreground(Color::Cyan) + .build(), + TextSpanBuilder::new(" to show keybindings; ") + .bold() + .build(), + TextSpanBuilder::new("") + .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 ", version))]))) + .with_spans( + vec![ + TextSpan::from("termscp "), + TextSpanBuilder::new(version).underlined().bold().build(), + TextSpan::from(" is now available! Download it from ") + ] + ) .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 = self + let bookmarks: Vec = 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 = self + let bookmarks: Vec = 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("") @@ -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(), + } + } } diff --git a/src/ui/activities/filetransfer/actions/change_dir.rs b/src/ui/activities/filetransfer/actions/change_dir.rs new file mode 100644 index 0000000..ad7ad09 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/change_dir.rs @@ -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); + } + } + } +} diff --git a/src/ui/activities/filetransfer/actions/copy.rs b/src/ui/activities/filetransfer/actions/copy.rs new file mode 100644 index 0000000..5809b8e --- /dev/null +++ b/src/ui/activities/filetransfer/actions/copy.rs @@ -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 + ), + ), + }, + } + } +} diff --git a/src/ui/activities/filetransfer/actions/delete.rs b/src/ui/activities/filetransfer/actions/delete.rs new file mode 100644 index 0000000..3d95a19 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/delete.rs @@ -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 + ), + ); + } + } + } +} diff --git a/src/ui/activities/filetransfer/actions/edit.rs b/src/ui/activities/filetransfer/actions/edit.rs new file mode 100644 index 0000000..ec88186 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/edit.rs @@ -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 = 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 = 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(); + } +} diff --git a/src/ui/activities/filetransfer/actions/exec.rs b/src/ui/activities/filetransfer/actions/exec.rs new file mode 100644 index 0000000..ba897c2 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/exec.rs @@ -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), + ); + } + } + } +} diff --git a/src/ui/activities/filetransfer/actions/find.rs b/src/ui/activities/filetransfer/actions/find.rs new file mode 100644 index 0000000..7e0a97d --- /dev/null +++ b/src/ui/activities/filetransfer/actions/find.rs @@ -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, 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, 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) { + 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); + } + } + } +} diff --git a/src/ui/activities/filetransfer/actions/mkdir.rs b/src/ui/activities/filetransfer/actions/mkdir.rs new file mode 100644 index 0000000..3664920 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/mkdir.rs @@ -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), + ); + } + } + } +} diff --git a/src/ui/activities/filetransfer/actions/mod.rs b/src/ui/activities/filetransfer/actions/mod.rs new file mode 100644 index 0000000..2e13003 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/mod.rs @@ -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), + None, +} + +enum SelectedEntryIndex { + One(usize), + Many(Vec), + None, +} + +impl From> for SelectedEntry { + fn from(opt: Option<&FsEntry>) -> Self { + match opt { + Some(e) => SelectedEntry::One(e.clone()), + None => SelectedEntry::None, + } + } +} + +impl From> 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 + .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 + .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 + .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 = files + .iter() + .map(|x| match x { + Value::Usize(v) => *v, + _ => 0, + }) + .collect(); + SelectedEntryIndex::Many(list) + } + _ => SelectedEntryIndex::None, + } + } +} diff --git a/src/ui/activities/filetransfer/actions/newfile.rs b/src/ui/activities/filetransfer/actions/newfile.rs new file mode 100644 index 0000000..ac65f79 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/newfile.rs @@ -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(); + } + } + } + } + } + } +} diff --git a/src/ui/activities/filetransfer/actions/rename.rs b/src/ui/activities/filetransfer/actions/rename.rs new file mode 100644 index 0000000..80f3fd5 --- /dev/null +++ b/src/ui/activities/filetransfer/actions/rename.rs @@ -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 + ), + ), + } + } +} diff --git a/src/ui/activities/filetransfer/actions/save.rs b/src/ui/activities/filetransfer/actions/save.rs new file mode 100644 index 0000000..508d99c --- /dev/null +++ b/src/ui/activities/filetransfer/actions/save.rs @@ -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) { + 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) { + 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 => {} + } + } +} diff --git a/src/ui/activities/filetransfer/lib/browser.rs b/src/ui/activities/filetransfer/lib/browser.rs new file mode 100644 index 0000000..5fe8147 --- /dev/null +++ b/src/ui/activities/filetransfer/lib/browser.rs @@ -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, // 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) { + 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() + } +} diff --git a/src/ui/activities/filetransfer/lib/mod.rs b/src/ui/activities/filetransfer/lib/mod.rs new file mode 100644 index 0000000..4864265 --- /dev/null +++ b/src/ui/activities/filetransfer/lib/mod.rs @@ -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; diff --git a/src/ui/activities/filetransfer/lib/transfer.rs b/src/ui/activities/filetransfer/lib/transfer.rs new file mode 100644 index 0000000..2fc7429 --- /dev/null +++ b/src/ui/activities/filetransfer/lib/transfer.rs @@ -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); + } +} diff --git a/src/ui/activities/filetransfer_activity/misc.rs b/src/ui/activities/filetransfer/misc.rs similarity index 72% rename from src/ui/activities/filetransfer_activity/misc.rs rename to src/ui/activities/filetransfer/misc.rs index cc37e65..b13e47e 100644 --- a/src/ui/activities/filetransfer_activity/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -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(), + } + } } diff --git a/src/ui/activities/filetransfer_activity/mod.rs b/src/ui/activities/filetransfer/mod.rs similarity index 69% rename from src/ui/activities/filetransfer_activity/mod.rs rename to src/ui/activities/filetransfer/mod.rs index 6831d16..5cedb60 100644 --- a/src/ui/activities/filetransfer_activity/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -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, // Exit reason context: Option, // Context holder view: View, // View + host: Localhost, // Localhost client: Box, // File transfer client - local: FileExplorer, // Local File explorer state - remote: FileExplorer, // Remote File explorer state - found: Option, // File explorer for find result - tab: FileExplorerTab, // Current selected tab + browser: Browser, // Browser log_records: VecDeque, // 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 = 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 { // 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(); diff --git a/src/ui/activities/filetransfer_activity/session.rs b/src/ui/activities/filetransfer/session.rs similarity index 59% rename from src/ui/activities/filetransfer_activity/session.rs rename to src/ui/activities/filetransfer/session.rs index a2f137f..d9aa06d 100644 --- a/src/ui/activities/filetransfer_activity/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -33,7 +33,9 @@ extern crate tempfile; // Locals use super::{FileTransferActivity, LogLevel}; +use crate::filetransfer::FileTransferError; use crate::fs::{FsEntry, FsFile}; +use crate::host::HostError; use crate::utils::fmt::fmt_millis; // Ext @@ -43,6 +45,26 @@ use std::fs::OpenOptions; use std::io::{Read, Seek, Write}; use std::path::{Path, PathBuf}; use std::time::{Instant, SystemTime}; +use thiserror::Error; + +/// ## TransferErrorReason +/// +/// Describes the reason that caused an error during a file transfer +#[derive(Error, Debug)] +enum TransferErrorReason { + #[error("File transfer aborted")] + Abrupted, + #[error("Failed to seek file: {0}")] + CouldNotRewind(std::io::Error), + #[error("I/O error on localhost: {0}")] + LocalIoError(std::io::Error), + #[error("Host error: {0}")] + HostError(HostError), + #[error("I/O error on remote: {0}")] + RemoteIoError(std::io::Error), + #[error("File transfer error: {0}")] + FileTransferError(FileTransferError), +} impl FileTransferActivity { /// ### connect @@ -64,7 +86,7 @@ impl FileTransferActivity { // Log welcome self.log( LogLevel::Info, - format!("Established connection with '{}': \"{}\"", addr, banner).as_ref(), + format!("Established connection with '{}': \"{}\"", addr, banner), ); } // Try to change directory to entry directory @@ -119,10 +141,15 @@ impl FileTransferActivity { if let Ok(pwd) = self.client.pwd() { self.remote_scan(pwd.as_path()); // Set wrkdir - self.remote.wrkdir = pwd; + self.remote_mut().wrkdir = pwd; } } + pub(super) fn reload_local_dir(&mut self) { + let wrkdir: PathBuf = self.local().wrkdir.clone(); + self.local_scan(wrkdir.as_path()); + } + /// ### filetransfer_send /// /// Send fs entry to remote. @@ -133,6 +160,25 @@ impl FileTransferActivity { entry: &FsEntry, curr_remote_path: &Path, dst_name: Option, + ) { + // Reset states + self.transfer.reset(); + // Calculate total size of transfer + let total_transfer_size: usize = self.get_total_transfer_size_local(entry); + self.transfer.full.init(total_transfer_size); + // Mount progress bar + self.mount_progress_bar(format!("Uploading {}...", entry.get_abs_path().display())); + // Send recurse + self.filetransfer_send_recurse(entry, curr_remote_path, dst_name); + // Umount progress bar + self.umount_progress_bar(); + } + + fn filetransfer_send_recurse( + &mut self, + entry: &FsEntry, + curr_remote_path: &Path, + dst_name: Option, ) { // Write popup let file_name: String = match entry { @@ -149,7 +195,44 @@ impl FileTransferActivity { // Match entry match entry { FsEntry::File(file) => { - let _ = self.filetransfer_send_file(file, remote_path.as_path(), file_name); + if let Err(err) = + self.filetransfer_send_file(file, remote_path.as_path(), file_name) + { + // Log error + self.log_and_alert( + LogLevel::Error, + format!("Failed to upload file {}: {}", file.name, err), + ); + // If transfer was abrupted or there was an IO error on remote, remove file + if matches!( + err, + TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_) + ) { + // Stat file on remote and remove it if exists + match self.client.stat(remote_path.as_path()) { + Err(err) => self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + remote_path.display(), + err + ), + ), + Ok(entry) => { + if let Err(err) = self.client.remove(&entry) { + self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + remote_path.display(), + err + ), + ); + } + } + } + } + } } FsEntry::Directory(dir) => { // Create directory on remote @@ -157,25 +240,23 @@ impl FileTransferActivity { Ok(_) => { self.log( LogLevel::Info, - format!("Created directory \"{}\"", remote_path.display()).as_ref(), + format!("Created directory \"{}\"", remote_path.display()), ); // Get files in dir - match self - .context - .as_ref() - .unwrap() - .local - .scan_dir(dir.abs_path.as_path()) - { + match self.host.scan_dir(dir.abs_path.as_path()) { Ok(entries) => { // Iterate over files for entry in entries.iter() { // If aborted; break - if self.transfer.aborted { + if self.transfer.aborted() { break; } // Send entry; name is always None after first call - self.filetransfer_send(&entry, remote_path.as_path(), None); + self.filetransfer_send_recurse( + &entry, + remote_path.as_path(), + None, + ); } } Err(err) => { @@ -204,24 +285,123 @@ impl FileTransferActivity { } } // Scan dir on remote - let path: PathBuf = self.remote.wrkdir.clone(); - self.remote_scan(path.as_path()); + self.reload_remote_dir(); // If aborted; show popup - if self.transfer.aborted { + if self.transfer.aborted() { // Log abort self.log_and_alert( LogLevel::Warn, format!("Upload aborted for \"{}\"!", entry.get_abs_path().display()), ); - // Set aborted to false - self.transfer.aborted = false; - } else { - // @! Successful - // Eventually, Remove progress bar - self.umount_progress_bar(); } } + /// ### filetransfer_send_file + /// + /// Send local file and write it to remote path + fn filetransfer_send_file( + &mut self, + local: &FsFile, + remote: &Path, + file_name: String, + ) -> Result<(), TransferErrorReason> { + // Upload file + // Try to open local file + match self.host.open_file_read(local.abs_path.as_path()) { + Ok(mut fhnd) => match self.client.send_file(local, remote) { + Ok(mut rhnd) => { + // Write file + let file_size: usize = + fhnd.seek(std::io::SeekFrom::End(0)).unwrap_or(0) as usize; + // Init transfer + self.transfer.partial.init(file_size); + // rewind + if let Err(err) = fhnd.seek(std::io::SeekFrom::Start(0)) { + return Err(TransferErrorReason::CouldNotRewind(err)); + } + // Write remote file + let mut total_bytes_written: usize = 0; + let mut last_progress_val: f64 = 0.0; + let mut last_input_event_fetch: Instant = Instant::now(); + // While the entire file hasn't been completely written, + // Or filetransfer has been aborted + while total_bytes_written < file_size && !self.transfer.aborted() { + // Handle input events (each 500ms) + if last_input_event_fetch.elapsed().as_millis() >= 500 { + // Read events + self.read_input_event(); + // Reset instant + last_input_event_fetch = Instant::now(); + } + // Read till you can + let mut buffer: [u8; 65536] = [0; 65536]; + let delta: usize = match fhnd.read(&mut buffer) { + Ok(bytes_read) => { + total_bytes_written += bytes_read; + if bytes_read == 0 { + continue; + } else { + let mut delta: usize = 0; + while delta < bytes_read { + // Write bytes + match rhnd.write(&buffer[delta..bytes_read]) { + Ok(bytes) => { + delta += bytes; + } + Err(err) => { + return Err(TransferErrorReason::RemoteIoError( + err, + )); + } + } + } + delta + } + } + Err(err) => { + return Err(TransferErrorReason::LocalIoError(err)); + } + }; + // Increase progress + self.transfer.partial.update_progress(delta); + self.transfer.full.update_progress(delta); + // Draw only if a significant progress has been made (performance improvement) + if last_progress_val < self.transfer.partial.calc_progress() - 0.01 { + // Draw + self.update_progress_bar(format!("Uploading \"{}\"...", file_name)); + self.view(); + last_progress_val = self.transfer.partial.calc_progress(); + } + } + // Finalize stream + if let Err(err) = self.client.on_sent(rhnd) { + self.log( + LogLevel::Warn, + format!("Could not finalize remote stream: \"{}\"", err), + ); + } + // if upload was abrupted, return error + if self.transfer.aborted() { + return Err(TransferErrorReason::Abrupted); + } + self.log( + LogLevel::Info, + format!( + "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", + local.abs_path.display(), + remote.display(), + fmt_millis(self.transfer.partial.started().elapsed()), + ByteSize(self.transfer.partial.calc_bytes_per_second()), + ), + ); + } + Err(err) => return Err(TransferErrorReason::FileTransferError(err)), + }, + Err(err) => return Err(TransferErrorReason::HostError(err)), + } + Ok(()) + } + /// ### filetransfer_recv /// /// Recv fs entry from remote. @@ -232,6 +412,25 @@ impl FileTransferActivity { entry: &FsEntry, local_path: &Path, dst_name: Option, + ) { + // Reset states + self.transfer.reset(); + // Calculate total transfer size + let total_transfer_size: usize = self.get_total_transfer_size_remote(entry); + self.transfer.full.init(total_transfer_size); + // Mount progress bar + self.mount_progress_bar(format!("Downloading {}...", entry.get_abs_path().display())); + // Receive + self.filetransfer_recv_recurse(entry, local_path, dst_name); + // Umount progress bar + self.umount_progress_bar(); + } + + fn filetransfer_recv_recurse( + &mut self, + entry: &FsEntry, + local_path: &Path, + dst_name: Option, ) { // Write popup let file_name: String = match entry { @@ -252,7 +451,39 @@ impl FileTransferActivity { if let Err(err) = self.filetransfer_recv_file(local_file_path.as_path(), file, file_name) { - self.log_and_alert(LogLevel::Error, err); + self.log_and_alert( + LogLevel::Error, + format!("Could not download file {}: {}", file.name, err), + ); + // If transfer was abrupted or there was an IO error on remote, remove file + if matches!( + err, + TransferErrorReason::Abrupted | TransferErrorReason::LocalIoError(_) + ) { + // Stat file + match self.host.stat(local_file_path.as_path()) { + Err(err) => self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + local_file_path.display(), + err + ), + ), + Ok(entry) => { + if let Err(err) = self.host.remove(&entry) { + self.log( + LogLevel::Error, + format!( + "Could not remove created file {}: {}", + local_file_path.display(), + err + ), + ); + } + } + } + } } } FsEntry::Directory(dir) => { @@ -263,24 +494,12 @@ impl FileTransferActivity { None => local_dir_path.push(dir.name.as_str()), } // Create directory on local - match self - .context - .as_mut() - .unwrap() - .local - .mkdir_ex(local_dir_path.as_path(), true) - { + match self.host.mkdir_ex(local_dir_path.as_path(), true) { Ok(_) => { // Apply file mode to directory #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] if let Some(pex) = dir.unix_pex { - if let Err(err) = self - .context - .as_ref() - .unwrap() - .local - .chmod(local_dir_path.as_path(), pex) - { + if let Err(err) = self.host.chmod(local_dir_path.as_path(), pex) { self.log( LogLevel::Error, format!( @@ -288,14 +507,13 @@ impl FileTransferActivity { pex, local_dir_path.display(), err - ) - .as_ref(), + ), ); } } self.log( LogLevel::Info, - format!("Created directory \"{}\"", local_dir_path.display()).as_ref(), + format!("Created directory \"{}\"", local_dir_path.display()), ); // Get files in dir match self.client.list_dir(dir.abs_path.as_path()) { @@ -303,12 +521,16 @@ impl FileTransferActivity { // Iterate over files for entry in entries.iter() { // If transfer has been aborted; break - if self.transfer.aborted { + if self.transfer.aborted() { break; } // Receive entry; name is always None after first call // Local path becomes local_dir_path - self.filetransfer_recv(&entry, local_dir_path.as_path(), None); + self.filetransfer_recv_recurse( + &entry, + local_dir_path.as_path(), + None, + ); } } Err(err) => { @@ -330,8 +552,7 @@ impl FileTransferActivity { "Failed to create directory \"{}\": {}", local_dir_path.display(), err - ) - .as_ref(), + ), ); } } @@ -340,7 +561,7 @@ impl FileTransferActivity { // Reload directory on local self.local_scan(local_path); // if aborted; show alert - if self.transfer.aborted { + if self.transfer.aborted() { // Log abort self.log_and_alert( LogLevel::Warn, @@ -349,140 +570,9 @@ impl FileTransferActivity { entry.get_abs_path().display() ), ); - // Reset aborted to false - self.transfer.aborted = false; - } else { - // Eventually, Reset input mode to explorer - self.umount_progress_bar(); } } - /// ### filetransfer_send_file - /// - /// Send local file and write it to remote path - fn filetransfer_send_file( - &mut self, - local: &FsFile, - remote: &Path, - file_name: String, - ) -> Result<(), String> { - // Upload file - // Try to open local file - match self - .context - .as_ref() - .unwrap() - .local - .open_file_read(local.abs_path.as_path()) - { - Ok(mut fhnd) => match self.client.send_file(local, remote) { - Ok(mut rhnd) => { - // Write file - let file_size: usize = - fhnd.seek(std::io::SeekFrom::End(0)).unwrap_or(0) as usize; - // rewind - if let Err(err) = fhnd.seek(std::io::SeekFrom::Start(0)) { - return Err(format!("Could not rewind local file: {}", err)); - } - // Write remote file - let mut total_bytes_written: usize = 0; - // Reset transfer states - self.transfer.reset(); - let mut last_progress_val: f64 = 0.0; - let mut last_input_event_fetch: Instant = Instant::now(); - // Mount progress bar - self.mount_progress_bar(); - // While the entire file hasn't been completely written, - // Or filetransfer has been aborted - while total_bytes_written < file_size && !self.transfer.aborted { - // Handle input events (each 500ms) - if last_input_event_fetch.elapsed().as_millis() >= 500 { - // Read events - self.read_input_event(); - // Reset instant - last_input_event_fetch = Instant::now(); - } - // Read till you can - let mut buffer: [u8; 65536] = [0; 65536]; - match fhnd.read(&mut buffer) { - Ok(bytes_read) => { - total_bytes_written += bytes_read; - if bytes_read == 0 { - continue; - } else { - let mut buf_start: usize = 0; - while buf_start < bytes_read { - // Write bytes - match rhnd.write(&buffer[buf_start..bytes_read]) { - Ok(bytes) => { - buf_start += bytes; - } - Err(err) => { - self.umount_progress_bar(); - return Err(format!( - "Could not write remote file: {}", - err - )); - } - } - } - } - } - Err(err) => { - self.umount_progress_bar(); - return Err(format!("Could not read local file: {}", err)); - } - } - // Increase progress - self.transfer.set_progress(total_bytes_written, file_size); - // Draw only if a significant progress has been made (performance improvement) - if last_progress_val < self.transfer.progress - 1.0 { - // Draw - self.update_progress_bar(format!("Uploading \"{}\"...", file_name)); - self.view(); - last_progress_val = self.transfer.progress; - } - } - // Umount progress bar - self.umount_progress_bar(); - // Finalize stream - if let Err(err) = self.client.on_sent(rhnd) { - self.log( - LogLevel::Warn, - format!("Could not finalize remote stream: \"{}\"", err).as_str(), - ); - } - self.log( - LogLevel::Info, - format!( - "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", - local.abs_path.display(), - remote.display(), - fmt_millis(self.transfer.started.elapsed()), - ByteSize(self.transfer.bytes_per_second()), - ) - .as_ref(), - ); - } - Err(err) => { - return Err(format!( - "Failed to upload file \"{}\": {}", - local.abs_path.display(), - err - )) - } - }, - Err(err) => { - return Err(format!( - "Failed to open file \"{}\": {}", - local.abs_path.display(), - err - )) - } - } - Ok(()) - } - /// ### filetransfer_recv_file /// /// Receive file from remote and write it to local path @@ -491,24 +581,22 @@ impl FileTransferActivity { local: &Path, remote: &FsFile, file_name: String, - ) -> Result<(), String> { + ) -> Result<(), TransferErrorReason> { // Try to open local file - match self.context.as_ref().unwrap().local.open_file_write(local) { + match self.host.open_file_write(local) { Ok(mut local_file) => { // Download file from remote match self.client.recv_file(remote) { Ok(mut rhnd) => { let mut total_bytes_written: usize = 0; - // Reset transfer states - self.transfer.reset(); + // Init transfer + self.transfer.partial.init(remote.size); // Write local file let mut last_progress_val: f64 = 0.0; let mut last_input_event_fetch: Instant = Instant::now(); - // Mount progress bar - self.mount_progress_bar(); // While the entire file hasn't been completely read, // Or filetransfer has been aborted - while total_bytes_written < remote.size && !self.transfer.aborted { + while total_bytes_written < remote.size && !self.transfer.aborted() { // Handle input events (each 500 ms) if last_input_event_fetch.elapsed().as_millis() >= 500 { // Read events @@ -518,57 +606,57 @@ impl FileTransferActivity { } // Read till you can let mut buffer: [u8; 65536] = [0; 65536]; - match rhnd.read(&mut buffer) { + let delta: usize = match rhnd.read(&mut buffer) { Ok(bytes_read) => { total_bytes_written += bytes_read; if bytes_read == 0 { continue; } else { - let mut buf_start: usize = 0; - while buf_start < bytes_read { + let mut delta: usize = 0; + while delta < bytes_read { // Write bytes - match local_file.write(&buffer[buf_start..bytes_read]) { - Ok(bytes) => buf_start += bytes, + match local_file.write(&buffer[delta..bytes_read]) { + Ok(bytes) => delta += bytes, Err(err) => { - self.umount_progress_bar(); - return Err(format!( - "Could not write local file: {}", - err + return Err(TransferErrorReason::LocalIoError( + err, )); } } } + delta } } Err(err) => { - self.umount_progress_bar(); - return Err(format!("Could not read remote file: {}", err)); + return Err(TransferErrorReason::RemoteIoError(err)); } - } + }; // Set progress - self.transfer.set_progress(total_bytes_written, remote.size); + self.transfer.partial.update_progress(delta); + self.transfer.full.update_progress(delta); // Draw only if a significant progress has been made (performance improvement) - if last_progress_val < self.transfer.progress - 1.0 { + if last_progress_val < self.transfer.partial.calc_progress() - 0.01 { // Draw self.update_progress_bar(format!("Downloading \"{}\"", file_name)); self.view(); - last_progress_val = self.transfer.progress; + last_progress_val = self.transfer.partial.calc_progress(); } } - // Umount progress bar - self.umount_progress_bar(); // Finalize stream if let Err(err) = self.client.on_recv(rhnd) { self.log( LogLevel::Warn, - format!("Could not finalize remote stream: \"{}\"", err).as_str(), + format!("Could not finalize remote stream: \"{}\"", err), ); } + // If download was abrupted, return Error + if self.transfer.aborted() { + return Err(TransferErrorReason::Abrupted); + } // Apply file mode to file #[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))] if let Some(pex) = remote.unix_pex { - if let Err(err) = self.context.as_ref().unwrap().local.chmod(local, pex) - { + if let Err(err) = self.host.chmod(local, pex) { self.log( LogLevel::Error, format!( @@ -576,8 +664,7 @@ impl FileTransferActivity { pex, local.display(), err - ) - .as_ref(), + ), ); } } @@ -588,28 +675,15 @@ impl FileTransferActivity { "Saved file \"{}\" to \"{}\" (took {} seconds; at {}/s)", remote.abs_path.display(), local.display(), - fmt_millis(self.transfer.started.elapsed()), - ByteSize(self.transfer.bytes_per_second()), - ) - .as_ref(), + fmt_millis(self.transfer.partial.started().elapsed()), + ByteSize(self.transfer.partial.calc_bytes_per_second()), + ), ); } - Err(err) => { - return Err(format!( - "Failed to download file \"{}\": {}", - remote.abs_path.display(), - err - )) - } + Err(err) => return Err(TransferErrorReason::FileTransferError(err)), } } - Err(err) => { - return Err(format!( - "Failed to open local file for write \"{}\": {}", - local.display(), - err - )) - } + Err(err) => return Err(TransferErrorReason::HostError(err)), } Ok(()) } @@ -618,10 +692,10 @@ impl FileTransferActivity { /// /// Scan current local directory pub(super) fn local_scan(&mut self, path: &Path) { - match self.context.as_ref().unwrap().local.scan_dir(path) { + match self.host.scan_dir(path) { Ok(files) => { // Set files and sort (sorting is implicit) - self.local.set_files(files); + self.local_mut().set_files(files); } Err(err) => { self.log_and_alert( @@ -639,7 +713,7 @@ impl FileTransferActivity { match self.client.list_dir(path) { Ok(files) => { // Set files and sort (sorting is implicit) - self.remote.set_files(files); + self.remote_mut().set_files(files); } Err(err) => { self.log_and_alert( @@ -655,21 +729,21 @@ impl FileTransferActivity { /// Change directory for local pub(super) fn local_changedir(&mut self, path: &Path, push: bool) { // Get current directory - let prev_dir: PathBuf = self.local.wrkdir.clone(); + let prev_dir: PathBuf = self.local().wrkdir.clone(); // Change directory - match self.context.as_mut().unwrap().local.change_wrkdir(path) { + match self.host.change_wrkdir(path) { Ok(_) => { self.log( LogLevel::Info, - format!("Changed directory on local: {}", path.display()).as_str(), + format!("Changed directory on local: {}", path.display()), ); // Reload files self.local_scan(path); // Set wrkdir - self.local.wrkdir = PathBuf::from(path); + self.local_mut().wrkdir = PathBuf::from(path); // Push prev_dir to stack if push { - self.local.pushd(prev_dir.as_path()) + self.local_mut().pushd(prev_dir.as_path()) } } Err(err) => { @@ -684,21 +758,21 @@ impl FileTransferActivity { pub(super) fn remote_changedir(&mut self, path: &Path, push: bool) { // Get current directory - let prev_dir: PathBuf = self.remote.wrkdir.clone(); + let prev_dir: PathBuf = self.remote().wrkdir.clone(); // Change directory match self.client.as_mut().change_dir(path) { Ok(_) => { self.log( LogLevel::Info, - format!("Changed directory on remote: {}", path.display()).as_str(), + format!("Changed directory on remote: {}", path.display()), ); // Update files self.remote_scan(path); // Set wrkdir - self.remote.wrkdir = PathBuf::from(path); + self.remote_mut().wrkdir = PathBuf::from(path); // Push prev_dir to stack if push { - self.remote.pushd(prev_dir.as_path()) + self.remote_mut().pushd(prev_dir.as_path()) } } Err(err) => { @@ -736,7 +810,9 @@ impl FileTransferActivity { } } // 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(); @@ -748,8 +824,7 @@ impl FileTransferActivity { format!( "Changes performed through editor saved to \"{}\"!", path.display() - ) - .as_str(), + ), ), Err(err) => return Err(format!("Could not open editor: {}", err)), } @@ -777,11 +852,10 @@ impl FileTransferActivity { }; // Download file if let Err(err) = self.filetransfer_recv_file(tmpfile.path(), file, file.name.clone()) { - return Err(err); + return Err(format!("Could not open file {}: {}", file.name, err)); } // Get current file modification time - let prev_mtime: SystemTime = match self.context.as_ref().unwrap().local.stat(tmpfile.path()) - { + let prev_mtime: SystemTime = match self.host.stat(tmpfile.path()) { Ok(e) => e.get_last_change_time(), Err(err) => { return Err(format!( @@ -796,8 +870,7 @@ impl FileTransferActivity { return Err(err); } // Get local fs entry - let tmpfile_entry: FsEntry = match self.context.as_ref().unwrap().local.stat(tmpfile.path()) - { + let tmpfile_entry: FsEntry = match self.host.stat(tmpfile.path()) { Ok(e) => e, Err(err) => { return Err(format!( @@ -815,21 +888,19 @@ impl FileTransferActivity { format!( "File \"{}\" has changed; writing changes to remote", file.abs_path.display() - ) - .as_ref(), + ), ); // Get local fs entry - let tmpfile_entry: FsEntry = - match self.context.as_ref().unwrap().local.stat(tmpfile.path()) { - Ok(e) => e, - Err(err) => { - return Err(format!( - "Could not stat \"{}\": {}", - tmpfile.path().display(), - err - )) - } - }; + let tmpfile_entry: FsEntry = match self.host.stat(tmpfile.path()) { + Ok(e) => e, + Err(err) => { + return Err(format!( + "Could not stat \"{}\": {}", + tmpfile.path().display(), + err + )) + } + }; // Write file let tmpfile_entry: &FsFile = match &tmpfile_entry { FsEntry::Directory(_) => panic!("tempfile is a directory for some reason"), @@ -841,16 +912,186 @@ impl FileTransferActivity { file.abs_path.as_path(), file.name.clone(), ) { - return Err(err); + return Err(format!( + "Could not write file {}: {}", + file.abs_path.display(), + err + )); } } false => { self.log( LogLevel::Info, - format!("File \"{}\" hasn't changed", file.abs_path.display()).as_ref(), + format!("File \"{}\" hasn't changed", file.abs_path.display()), ); } } Ok(()) } + + /// ### tricky_copy + /// + /// Tricky copy will be used whenever copy command is not available on remote host + pub(super) fn tricky_copy(&mut self, entry: &FsEntry, dest: &Path) { + // match entry + match entry { + FsEntry::File(entry) => { + // Create tempfile + let tmpfile: tempfile::NamedTempFile = match tempfile::NamedTempFile::new() { + Ok(f) => f, + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Copy failed: could not create temporary file: {}", err), + ); + return; + } + }; + // Download file + if let Err(err) = + self.filetransfer_recv_file(tmpfile.path(), entry, entry.name.clone()) + { + self.log_and_alert( + LogLevel::Error, + format!("Copy failed: could not download to temporary file: {}", err), + ); + return; + } + // Get local fs entry + let tmpfile_entry: FsEntry = match self.host.stat(tmpfile.path()) { + Ok(e) => e, + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!( + "Copy failed: could not stat \"{}\": {}", + tmpfile.path().display(), + err + ), + ); + return; + } + }; + let tmpfile_entry = match &tmpfile_entry { + FsEntry::Directory(_) => panic!("tempfile is a directory for some reason"), + FsEntry::File(f) => f, + }; + // Upload file to destination + if let Err(err) = self.filetransfer_send_file( + tmpfile_entry, + dest, + String::from(dest.to_string_lossy()), + ) { + self.log_and_alert( + LogLevel::Error, + format!( + "Copy failed: could not write file {}: {}", + entry.abs_path.display(), + err + ), + ); + return; + } + } + FsEntry::Directory(_) => { + let tempdir: tempfile::TempDir = match tempfile::TempDir::new() { + Ok(d) => d, + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!("Copy failed: could not create temporary directory: {}", err), + ); + return; + } + }; + // Download file + self.filetransfer_recv(entry, tempdir.path(), None); + // Get path of dest + let mut tempdir_path: PathBuf = tempdir.path().to_path_buf(); + tempdir_path.push(entry.get_name()); + // Stat dir + let tempdir_entry: FsEntry = match self.host.stat(tempdir_path.as_path()) { + Ok(e) => e, + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!( + "Copy failed: could not stat \"{}\": {}", + tempdir.path().display(), + err + ), + ); + return; + } + }; + // Upload to destination + let wrkdir: PathBuf = self.remote().wrkdir.clone(); + self.filetransfer_send( + &tempdir_entry, + wrkdir.as_path(), + Some(String::from(dest.to_string_lossy())), + ); + } + } + } + + // -- transfer sizes + + /// ### get_total_transfer_size_local + /// + /// Get total size of transfer for localhost + fn get_total_transfer_size_local(&mut self, entry: &FsEntry) -> usize { + match entry { + FsEntry::File(file) => file.size, + FsEntry::Directory(dir) => { + // List dir + match self.host.scan_dir(dir.abs_path.as_path()) { + Ok(files) => files + .iter() + .map(|x| self.get_total_transfer_size_local(x)) + .sum(), + Err(err) => { + self.log( + LogLevel::Error, + format!( + "Could not list directory {}: {}", + dir.abs_path.display(), + err + ), + ); + 0 + } + } + } + } + } + + /// ### get_total_transfer_size_remote + /// + /// Get total size of transfer for remote host + fn get_total_transfer_size_remote(&mut self, entry: &FsEntry) -> usize { + match entry { + FsEntry::File(file) => file.size, + FsEntry::Directory(dir) => { + // List directory + match self.client.list_dir(dir.abs_path.as_path()) { + Ok(files) => files + .iter() + .map(|x| self.get_total_transfer_size_remote(x)) + .sum(), + Err(err) => { + self.log( + LogLevel::Error, + format!( + "Could not list directory {}: {}", + dir.abs_path.display(), + err + ), + ); + 0 + } + } + } + } + } } diff --git a/src/ui/activities/filetransfer_activity/update.rs b/src/ui/activities/filetransfer/update.rs similarity index 62% rename from src/ui/activities/filetransfer_activity/update.rs rename to src/ui/activities/filetransfer/update.rs index 62e971a..ecb9292 100644 --- a/src/ui/activities/filetransfer_activity/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -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 = 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 = 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 = 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 = 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, String> = match self.tab { + let res: Result, 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 = self - .local + let files: Vec = 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 = self - .remote + let files: Vec = 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 = self - .found - .as_ref() + let files: Vec = 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) } diff --git a/src/ui/activities/filetransfer_activity/view.rs b/src/ui/activities/filetransfer/view.rs similarity index 69% rename from src/ui/activities/filetransfer_activity/view.rs rename to src/ui/activities/filetransfer/view.rs index 15269a2..bab6290 100644 --- a/src/ui/activities/filetransfer_activity/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -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 = 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("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Select file")) + .add_row() .add_col( TextSpanBuilder::new("") .bold() @@ -968,6 +1064,22 @@ impl FileTransferActivity { ) .add_col(TextSpan::from(" Go to parent directory")) .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Execute shell command")) + .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Toggle synchronized browsing")) + .add_row() .add_col( TextSpanBuilder::new("") .bold() @@ -976,6 +1088,14 @@ impl FileTransferActivity { ) .add_col(TextSpan::from(" Delete selected file")) .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Select all files")) + .add_row() .add_col( TextSpanBuilder::new("") .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", + } + } } diff --git a/src/ui/activities/filetransfer_activity/actions.rs b/src/ui/activities/filetransfer_activity/actions.rs deleted file mode 100644 index ebbb778..0000000 --- a/src/ui/activities/filetransfer_activity/actions.rs +++ /dev/null @@ -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 = 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 = 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, 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, 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) { - let entry: Option = 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 = 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 { - 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 { - match self.view.get_value(super::COMPONENT_EXPLORER_REMOTE) { - Some(Payload::Unsigned(idx)) => Some(idx), - _ => None, - } - } -} diff --git a/src/ui/activities/mod.rs b/src/ui/activities/mod.rs index 307b73a..488dfaa 100644 --- a/src/ui/activities/mod.rs +++ b/src/ui/activities/mod.rs @@ -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 diff --git a/src/ui/activities/setup_activity/actions.rs b/src/ui/activities/setup/actions.rs similarity index 89% rename from src/ui/activities/setup_activity/actions.rs rename to src/ui/activities/setup/actions.rs index db7a45a..d965d42 100644 --- a/src/ui/activities/setup_activity/actions.rs +++ b/src/ui/activities/setup/actions.rs @@ -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 = match self.view.get_value(super::COMPONENT_LIST_SSH_KEYS) { - Some(Payload::Unsigned(idx)) => Some(idx), + let idx: Option = match self.view.get_state(super::COMPONENT_LIST_SSH_KEYS) { + Some(Payload::One(Value::Usize(idx))) => Some(idx), _ => None, }; if let Some(idx) = idx { @@ -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) => { diff --git a/src/ui/activities/setup_activity/config.rs b/src/ui/activities/setup/config.rs similarity index 95% rename from src/ui/activities/setup_activity/config.rs rename to src/ui/activities/setup/config.rs index 5a235df..25c1b0d 100644 --- a/src/ui/activities/setup_activity/config.rs +++ b/src/ui/activities/setup/config.rs @@ -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 } diff --git a/src/ui/activities/setup_activity/mod.rs b/src/ui/activities/setup/mod.rs similarity index 93% rename from src/ui/activities/setup_activity/mod.rs rename to src/ui/activities/setup/mod.rs index 6bea45f..15966fa 100644 --- a/src/ui/activities/setup_activity/mod.rs +++ b/src/ui/activities/setup/mod.rs @@ -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 { // 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() { diff --git a/src/ui/activities/setup_activity/update.rs b/src/ui/activities/setup/update.rs similarity index 86% rename from src/ui/activities/setup_activity/update.rs rename to src/ui/activities/setup/update.rs index 39e699c..053d7b5 100644 --- a/src/ui/activities/setup_activity/update.rs +++ b/src/ui/activities/setup/update.rs @@ -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 - (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 or @@ -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 } // 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()); diff --git a/src/ui/activities/setup_activity/view.rs b/src/ui/activities/setup/view.rs similarity index 63% rename from src/ui/activities/setup_activity/view.rs rename to src/ui/activities/setup/view.rs index a4176ac..7c37187 100644 --- a/src/ui/activities/setup_activity/view.rs +++ b/src/ui/activities/setup/view.rs @@ -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("") - .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("") + .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("") - .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("") + .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 = 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 = cli + let keys: Vec = 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); } diff --git a/src/ui/layout/components/bookmark_list.rs b/src/ui/components/bookmark_list.rs similarity index 61% rename from src/ui/layout/components/bookmark_list.rs rename to src/ui/components/bookmark_list.rs index b64218f..a541db7 100644 --- a/src/ui/layout/components/bookmark_list.rs +++ b/src/ui/components/bookmark_list.rs @@ -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, +} + +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 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, bookmarks: Vec) -> &mut Self { + if let Some(props) = self.props.as_mut() { + let bookmarks: Vec = 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 = match self.props.texts.rows.as_ref() { + let list_item: Vec = 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)) ); } diff --git a/src/ui/components/file_list.rs b/src/ui/components/file_list.rs new file mode 100644 index 0000000..ec21799 --- /dev/null +++ b/src/ui/components/file_list.rs @@ -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, +} + +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 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, files: Vec) -> &mut Self { + if let Some(props) = self.props.as_mut() { + let files: Vec = 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, // 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 { + 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 = 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))); + } +} diff --git a/src/ui/layout/components/logbox.rs b/src/ui/components/logbox.rs similarity index 64% rename from src/ui/layout/components/logbox.rs rename to src/ui/components/logbox.rs index e785a41..6237a7a 100644 --- a/src/ui/layout/components/logbox.rs +++ b/src/ui/components/logbox.rs @@ -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, +} + +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 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, 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 = match self.props.texts.table.as_ref() { None => Vec::new(), Some(table) => table .iter() - .map(|row| { - let mut columns: VecDeque = 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 = Vec::with_capacity(columns.len() - 4); - // Get first row - let mut first_row: Vec = 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)) ); } diff --git a/src/ui/layout/components/mod.rs b/src/ui/components/mod.rs similarity index 84% rename from src/ui/layout/components/mod.rs rename to src/ui/components/mod.rs index 297d16d..79831e5 100644 --- a/src/ui/layout/components/mod.rs +++ b/src/ui/components/mod.rs @@ -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; diff --git a/src/ui/layout/components/msgbox.rs b/src/ui/components/msgbox.rs similarity index 50% rename from src/ui/layout/components/msgbox.rs rename to src/ui/components/msgbox.rs index bee1260..8b21152 100644 --- a/src/ui/layout/components/msgbox.rs +++ b/src/ui/components/msgbox.rs @@ -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, +} + +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 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, texts: Vec) -> &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 = match self.props.texts.rows.as_ref() { + let lines: Vec = match self.props.texts.spans.as_ref() { None => Vec::new(), Some(rows) => { let mut lines: Vec = 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("") .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)) ); } diff --git a/src/ui/context.rs b/src/ui/context.rs index 3c7486b..e3d44ef 100644 --- a/src/ui/context.rs +++ b/src/ui/context.rs @@ -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, pub(crate) config_client: Option, pub(crate) store: Store, @@ -74,16 +72,11 @@ impl Context { /// ### new /// /// Instantiates a new Context - pub fn new( - local: Localhost, - config_client: Option, - error: Option, - ) -> Context { + pub fn new(config_client: Option, error: Option) -> 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 { - 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) - } - */ } diff --git a/src/ui/activities/keymap.rs b/src/ui/keymap.rs similarity index 98% rename from src/ui/activities/keymap.rs rename to src/ui/keymap.rs index 84425c0..0540013 100644 --- a/src/ui/activities/keymap.rs +++ b/src/ui/keymap.rs @@ -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, diff --git a/src/ui/layout/components/file_list.rs b/src/ui/layout/components/file_list.rs deleted file mode 100644 index ed3c094..0000000 --- a/src/ui/layout/components/file_list.rs +++ /dev/null @@ -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 = 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)) - ); - } -} diff --git a/src/ui/layout/components/input.rs b/src/ui/layout/components/input.rs deleted file mode 100644 index 632f349..0000000 --- a/src/ui/layout/components/input.rs +++ /dev/null @@ -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, // 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) { - // 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::().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); - } -} diff --git a/src/ui/layout/components/progress_bar.rs b/src/ui/layout/components/progress_bar.rs deleted file mode 100644 index 585f142..0000000 --- a/src/ui/layout/components/progress_bar.rs +++ /dev/null @@ -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)) - ); - } -} diff --git a/src/ui/layout/components/radio_group.rs b/src/ui/layout/components/radio_group.rs deleted file mode 100644 index 7132425..0000000 --- a/src/ui/layout/components/radio_group.rs +++ /dev/null @@ -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, // 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 = 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'))), - ); - } -} diff --git a/src/ui/layout/components/table.rs b/src/ui/layout/components/table.rs deleted file mode 100644 index 9062307..0000000 --- a/src/ui/layout/components/table.rs +++ /dev/null @@ -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 = match self.props.texts.table.as_ref() { - None => Vec::new(), - Some(table) => table - .iter() - .map(|row| { - let columns: Vec = 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)) - ); - } -} diff --git a/src/ui/layout/components/text.rs b/src/ui/layout/components/text.rs deleted file mode 100644 index 8aff07b..0000000 --- a/src/ui/layout/components/text.rs +++ /dev/null @@ -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 = 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("") - .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)) - ); - } -} diff --git a/src/ui/layout/components/title.rs b/src/ui/layout/components/title.rs deleted file mode 100644 index 52e3091..0000000 --- a/src/ui/layout/components/title.rs +++ /dev/null @@ -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)) - ); - } -} diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs deleted file mode 100644 index 9151c3f..0000000 --- a/src/ui/layout/mod.rs +++ /dev/null @@ -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; -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); -} diff --git a/src/ui/layout/props.rs b/src/ui/layout/props.rs deleted file mode 100644 index cc1a50c..0000000 --- a/src/ui/layout/props.rs +++ /dev/null @@ -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, // 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, -} - -#[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 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>; - -/// ## TextParts -/// -/// TextParts holds optional component for the text displayed by a component -#[derive(Clone)] -pub struct TextParts { - pub title: Option, - pub rows: Option>, - pub table: Option, // First vector is rows, inner vec is column -} - -impl TextParts { - /// ### new - /// - /// Instantiates a new TextParts entity - pub fn new(title: Option, rows: Option>) -> Self { - TextParts { - title, - rows, - table: None, - } - } - - /// ### table - /// - /// Instantiates a new TextParts as a Table - pub fn table(title: Option, 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
, -} - -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 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, -} - -#[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)); - } -} diff --git a/src/ui/layout/view.rs b/src/ui/layout/view.rs deleted file mode 100644 index ed8a3d9..0000000 --- a/src/ui/layout/view.rs +++ /dev/null @@ -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>, // all the components in the view - focus: Option, // Current active component - focus_stack: Vec, // 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) { - 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 { - 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 { - 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 = 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 { - 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 { - Box::new(Text::new( - PropsBuilder::default() - .with_texts(TextParts::new( - None, - Some(vec![TextSpan::from("Sample text")]), - )) - .build(), - )) - } -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2b23a1b..268a5ab 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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; diff --git a/src/ui/store.rs b/src/ui/store.rs index 185654a..5d49d0f 100644 --- a/src/ui/store.rs +++ b/src/ui/store.rs @@ -175,6 +175,8 @@ mod tests { use super::*; + use pretty_assertions::assert_eq; + #[test] fn test_ui_store() { // Create store diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 45314d7..a7a54e6 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -52,6 +52,8 @@ mod tests { use super::*; + use pretty_assertions::assert_eq; + #[test] fn test_utils_crypto_aes128() { let key: &str = "MYSUPERSECRETKEY"; diff --git a/src/utils/file.rs b/src/utils/file.rs new file mode 100644 index 0000000..46c3018 --- /dev/null +++ b/src/utils/file.rs @@ -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

(filename: P, create: bool, write: bool, append: bool) -> io::Result +where + P: AsRef, +{ + 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()); + } +} diff --git a/src/utils/fmt.rs b/src/utils/fmt.rs index 9e8a636..8f3a210 100644 --- a/src/utils/fmt.rs +++ b/src/utils/fmt.rs @@ -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("******")); + } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 3daed62..e11bd67 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -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; diff --git a/src/utils/parser.rs b/src/utils/parser.rs index 54fa7bc..b5a8f41 100644 --- a/src/utils/parser.rs +++ b/src/utils/parser.rs @@ -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 diff --git a/src/utils/random.rs b/src/utils/random.rs index 7a0010c..0cc2ad5 100644 --- a/src/utils/random.rs +++ b/src/utils/random.rs @@ -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); diff --git a/src/ui/layout/utils.rs b/src/utils/ui.rs similarity index 94% rename from src/ui/layout/utils.rs rename to src/utils/ui.rs index 6968d00..43cf147 100644 --- a/src/ui/layout/utils.rs +++ b/src/utils/ui.rs @@ -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);