Merged 0.5.0 into main
5
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -28,6 +28,11 @@ A clear and concise description of what you expected to happen.
|
||||
- Protocol used
|
||||
- Remote server version and name
|
||||
|
||||
## Log
|
||||
|
||||
Report the snippet of the log file containing the unexpected behaviour.
|
||||
If there is any information you consider to be confidential, shadow it.
|
||||
|
||||
## Additional information
|
||||
|
||||
Add any other context about the problem here.
|
||||
|
||||
14
.github/actions-rs/grcov.yml
vendored
Normal file
@@ -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
|
||||
28
.github/workflows/coverage.yml
vendored
@@ -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 }}
|
||||
|
||||
51
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 `<Y>`
|
||||
- Read more on manual: [Synchronized browsing](docs/man.md#Synchronized-browsing-)
|
||||
- **Remote and Local hosts file formatter**:
|
||||
- Added the possibility to set different formatters for local and remote hosts
|
||||
- **Work on multiple files**:
|
||||
- Added the possibility to work on **multiple files simultaneously**
|
||||
- Select a file with `<M>`, the file when selected will have a `*` prepended to its name
|
||||
- Select all files in the current directory with `<CTRL+A>`
|
||||
- Read more on manual: [Work on multiple files](docs/man.md#Work-on-multiple-files-)
|
||||
- **Logging**:
|
||||
- termscp now writes a log file, useful to debug and to contribute to fix issues.
|
||||
- Read more on [manual](docs/man.md)
|
||||
- **File transfer changes**
|
||||
- *SFTP*
|
||||
- Added **COPY** command to SFTP (Please note that Copy command is not supported by SFTP natively, so here it just uses the `cp` shell command as it does in SCP).
|
||||
- *FTP*
|
||||
- Added support for file copy (achieved through *tricky-copy*: the file is first downloaded, then uploaded with a different file name)
|
||||
- **Double progress bar**:
|
||||
- From now one two progress bar will be displayed:
|
||||
- the first, on top, displays the full transfer state (e.g. when downloading a directory of 10 files, the progress of the entire transfer)
|
||||
- the second, on bottom, displays the transfer of the individual file being written (as happened for the old versions)
|
||||
- changed the progress bar colour from `LightGreen` to `Green`
|
||||
- Enhancements
|
||||
- Added a status bar in the file explorer showing whether the sync browser is enabled and which file sorting mode is selected
|
||||
- Removed the goold old figlet title
|
||||
- Protocol input as first field in UI
|
||||
- Port is now updated to standard for selected protocol
|
||||
- when you change the protocol in the authentication form and the current port is standard (`< 1024`), the port will be automatically changed to default value for the selected protocol (e.g. current port: `123`, protocol changed to `FTP`, port becomes `21`)
|
||||
- Bugfix:
|
||||
- Fixed wrong text wrap in log box
|
||||
- Fixed empty bookmark name causing termscp to crash
|
||||
- Fixed error message not being shown after an upload failure
|
||||
- Fixed default protocol not being loaded from config
|
||||
- [Issue 23](https://github.com/veeso/termscp/issues/23): Remove created file if transfer failed or was abrupted
|
||||
- Dependencies:
|
||||
- Added `tui-realm 0.3.0`
|
||||
- Removed `tui` (as direct dependency)
|
||||
- Updated `regex` to `1.5.4`
|
||||
|
||||
## 0.4.2
|
||||
|
||||
Released on 13/04/2021
|
||||
@@ -88,7 +137,7 @@ Released on 28/02/2021
|
||||
- Added `EXTRA` and `LENGTH` parameters to format keys.
|
||||
- Now keys are provided with this syntax `{KEY_NAME[:LEN[:EXTRA]}`
|
||||
- **Check for updates**:
|
||||
- TermSCP will now check for updates on startup and will show in the main page if there is a new version available
|
||||
- termscp will now check for updates on startup and will show in the main page if there is a new version available
|
||||
- This feature may be disabled from setup (Check for updates => No)
|
||||
- Enhancements:
|
||||
- Default choice for deleting file set to "NO" (way too easy to delete files by mistake)
|
||||
|
||||
@@ -19,16 +19,16 @@ Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in
|
||||
|
||||
## Project mission
|
||||
|
||||
TermSCP was born because, as a terminal lover and Linux user, I wanted something like WinSCP on Linux and on terminal. I my previous job I used SFTP/SCP pratically everyday and that made me to desire an application like termscp so much, that eventually I started to work on it in the spare time. I saw there was a very cool library to create terminal user interface (`tui-rs`), so I started to code it. I wrote termscp as an experiment, I designed kinda nothing at the time. I just said
|
||||
termscp was born because, as a terminal lover and Linux user, I wanted something like WinSCP on Linux and on terminal. I my previous job I used SFTP/SCP pratically everyday and that made me to desire an application like termscp so much, that eventually I started to work on it in the spare time. I saw there was a very cool library to create terminal user interface (`tui-rs`), so I started to code it. I wrote termscp as an experiment, I designed kinda nothing at the time. I just said
|
||||
|
||||
> Ok, there must be a `FileTransfer` trait somehow, I'll have more views, so I'll use something like Android activities, and there must be a module to interact with the local host".
|
||||
|
||||
And so in december 2020 I had the first version of termscp running and it worked, but was very simple, raw and minimal.
|
||||
A lot of things have changed since them, both the features the project provides and my personal view of this project.
|
||||
|
||||
Today I don't see TermSCP as a WinSCP clone anymore. I've also thought about changing the name as the time passed by, but I liked it and it would be hard to change the name on the registries, etc.
|
||||
Today I don't see termscp as a WinSCP clone anymore. I've also thought about changing the name as the time passed by, but I liked it and it would be hard to change the name on the registries, etc.
|
||||
|
||||
Right now I see TermSCP as a **rich-featured file transfer client for terminals**. All I want is to provide all the features users need to use it correctly, I want it to be **safe and reliable** and eventually I want people to consider termscp **the first choice as a file transfer client**.
|
||||
Right now I see termscp as a **rich-featured file transfer client for terminals**. All I want is to provide all the features users need to use it correctly, I want it to be **safe and reliable** and eventually I want people to consider termscp **the first choice as a file transfer client**.
|
||||
|
||||
### Project goals
|
||||
|
||||
@@ -61,6 +61,7 @@ Don't set other labels to your issue, not even priority.
|
||||
|
||||
When you open a bug try to be the most precise as possible in describing your issue. I'm not saying you should always be that precise, since sometimes it's very easy for maintainers to understand what you're talking about. Just try to be reasonable to understand sometimes we might not know what you're talking about or we just don't have the technical knowledge you might think.
|
||||
Please always provide the environment you're working on and consider that we don't provide any support for older version of termscp, at least for those not classified as LTS (if we'll ever have them).
|
||||
If you can, provide the log file or the snippet involving your issue. You can find in the [user manual](docs/man.md) the location of the log file.
|
||||
Last but not least: the template I've written must be used. Full stop.
|
||||
|
||||
Maintainers will may add additional labels to your issue:
|
||||
@@ -68,7 +69,7 @@ Maintainers will may add additional labels to your issue:
|
||||
- **duplicate**: the issue is duplicated; the reference to the related issue will be added to your description. Your issue will be closed.
|
||||
- **priority**: this must be fixed asap
|
||||
- **sorcery**: it is not possible to find out what's causing your bug, nor is reproducible on our test environments.
|
||||
- **wontfix**: your bug has a very high ratio between the probability to encounter it and the difficult to fix it, or it just isn't a bug, but a feature.
|
||||
- **wontfix**: your bug has a very high ratio between the difficulty to fix it and the probability to encounter it, or it just isn't a bug, but a feature.
|
||||
|
||||
### Feature requests
|
||||
|
||||
|
||||
353
Cargo.lock
generated
@@ -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"
|
||||
|
||||
17
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"
|
||||
|
||||
197
README.md
@@ -1,44 +1,28 @@
|
||||
# TermSCP
|
||||
# termscp
|
||||
|
||||
<p align="center">
|
||||
<img src="/assets/images/termscp.svg" width="256" height="256" />
|
||||
</p>
|
||||
|
||||
[](https://opensource.org/licenses/MIT) [](https://github.com/veeso/termscp) [](https://crates.io/crates/termscp) [](https://crates.io/crates/termscp) [](https://docs.rs/termscp)
|
||||
[](https://opensource.org/licenses/MIT) [](https://github.com/veeso/termscp) [](https://crates.io/crates/termscp) [](https://crates.io/crates/termscp) [](https://docs.rs/termscp)
|
||||
|
||||
[](https://github.com/veeso/termscp/actions) [](https://github.com/veeso/termscp/actions) [](https://github.com/veeso/termscp/actions)
|
||||
[](https://github.com/veeso/termscp/actions) [](https://github.com/veeso/termscp/actions) [](https://github.com/veeso/termscp/actions) [](https://coveralls.io/github/veeso/termscp)
|
||||
|
||||
~ A feature rich terminal file transfer ~
|
||||
Developed by Christian Visintin
|
||||
Current version: 0.4.2 (13/04/2021)
|
||||
<p align="center">~ A feature rich terminal file transfer ~</p>
|
||||
<p align="center">
|
||||
<a href="https://veeso.github.io/termscp/" target="_blank">Website</a>
|
||||
·
|
||||
<a href="https://veeso.github.io/termscp/#get-started" target="_blank">Installation</a>
|
||||
·
|
||||
<a href="https://veeso.github.io/termscp/#user-manual" target="_blank">User manual</a>
|
||||
</p>
|
||||
|
||||
<p align="center">Developed by Christian Visintin</p>
|
||||
<p align="center">Current version: 0.5.0 (23/05/2021)</p>
|
||||
|
||||
---
|
||||
|
||||
- [TermSCP](#termscp)
|
||||
- [About TermSCP 🖥](#about-termscp-)
|
||||
- [Why TermSCP 🤔](#why-termscp-)
|
||||
- [Features 🎁](#features-)
|
||||
- [Installation 🛠](#installation-)
|
||||
- [Cargo 🦀](#cargo-)
|
||||
- [Deb package 📦](#deb-package-)
|
||||
- [RPM package 📦](#rpm-package-)
|
||||
- [AUR Package 🔼](#aur-package-)
|
||||
- [Chocolatey 🍫](#chocolatey-)
|
||||
- [Brew 🍻](#brew-)
|
||||
- [User Manual 🎓](#user-manual-)
|
||||
- [Documentation 📚](#documentation-)
|
||||
- [Known issues 🧻](#known-issues-)
|
||||
- [Upcoming Features 🧪](#upcoming-features-)
|
||||
- [Contributing and issues 🤝🏻](#contributing-and-issues-)
|
||||
- [Changelog ⏳](#changelog-)
|
||||
- [Powered by 🚀](#powered-by-)
|
||||
- [Gallery 🎬](#gallery-)
|
||||
- [Buy me a coffee ☕](#buy-me-a-coffee-)
|
||||
- [License 📃](#license-)
|
||||
|
||||
---
|
||||
|
||||
## About TermSCP 🖥
|
||||
## About termscp 🖥
|
||||
|
||||
Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP. So basically is a terminal utility with an TUI to connect to a remote server to retrieve and upload files and to interact with the local file system. It is **Linux**, **MacOS**, **BSD** and **Windows** compatible and supports SFTP, SCP, FTP and FTPS.
|
||||
|
||||
@@ -46,125 +30,52 @@ Termscp is a feature rich terminal file transfer and explorer, with support for
|
||||
|
||||
---
|
||||
|
||||
### Why TermSCP 🤔
|
||||
|
||||
It happens quite often to me, when using SCP at work to forget the path of a file on a remote machine, which forces me to connect through SSH, gather the file path and finally download it through SCP. I could use WinSCP, but I use Linux and I pratically use the terminal for everything, so I wanted something like WinSCP on my terminal. Yeah, I know there is midnight commander too, but actually I don't like it very much tbh (and hasn't a decent support for scp).
|
||||
|
||||
## Features 🎁
|
||||
|
||||
- 📁 Different communication protocols support
|
||||
- 📁 Different communication protocols support
|
||||
- SFTP
|
||||
- SCP
|
||||
- FTP and FTPS
|
||||
- 🐧 Compatible with Windows, Linux, BSD and MacOS
|
||||
- 🖥 Handy user interface to explore and operate on the remote and on the local machine file system
|
||||
- 🖥 Explore and operate on the remote and on the local machine file system with a handy UI
|
||||
- Create, remove, rename, search, view and edit files
|
||||
- ⭐ Bookmarks and recent connections can be saved to access quickly to your favourite hosts
|
||||
- 📝 Supports text editors to view and edit text files
|
||||
- 💁 Supports both SFTP/SCP authentication through SSH keys and username/password
|
||||
- ✏ Customizations
|
||||
- ⭐ Connect to your favourite hosts through built-in bookmarks and recent connections
|
||||
- 📝 View and edit text files with your favourite text editor
|
||||
- 💁 SFTP/SCP authentication through SSH keys and username/password
|
||||
- 🐧 Compatible with Windows, Linux, BSD and MacOS
|
||||
- ✏ Customizable
|
||||
- Custom file explorer format
|
||||
- Customizable text editor
|
||||
- Customizable file sorting
|
||||
- 🔐 SSH key storage
|
||||
- 🦀 Written in Rust
|
||||
- 🤝 Easy to extend with new file transfers protocols
|
||||
- 👀 Developed keeping an eye on performance
|
||||
- 🦄 Frequent awesome updates
|
||||
- 🔐 Save your password in your operating system key vault
|
||||
- 🦀 Rust-powered
|
||||
- 🤝 Easy to extend with new file transfers protocols
|
||||
- 👀 Developed keeping an eye on performance
|
||||
- 🦄 Frequent awesome updates
|
||||
|
||||
---
|
||||
|
||||
## Installation 🛠
|
||||
## Get started 🚀
|
||||
|
||||
If you're considering to install TermSCP I want to thank you 💜 ! I hope you will enjoy TermSCP!
|
||||
If you're considering to install termscp I want to thank you 💜 ! I hope you will enjoy termscp!
|
||||
If you want to contribute to this project, don't forget to check out our contribute guide. [Read More](CONTRIBUTING.md)
|
||||
|
||||
### Cargo 🦀
|
||||
If you are a Linux or a MacOS user this simple shell script will install termscp on your system with a single command:
|
||||
|
||||
```sh
|
||||
# Install termscp through cargo
|
||||
cargo install termscp
|
||||
curl --proto '=https' --tlsv1.2 -sSf "https://raw.githubusercontent.com/veeso/termscp/main/install.sh" | sh
|
||||
```
|
||||
|
||||
Requirements:
|
||||
while if you're a Windows user, you can install termscp with [Chocolatey](https://chocolatey.org/).
|
||||
|
||||
- Linux
|
||||
- pkg-config
|
||||
- libssh2
|
||||
- openssl
|
||||
|
||||
### Deb package 📦
|
||||
|
||||
Get `deb` package from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp_0.4.2_amd64.deb)
|
||||
or run `wget https://github.com/veeso/termscp/releases/latest/download/termscp_0.4.2_amd64.deb`
|
||||
|
||||
then install through dpkg:
|
||||
|
||||
```sh
|
||||
dpkg -i termscp_*.deb
|
||||
# Or even better with gdebi
|
||||
gdebi termscp_*.deb
|
||||
```
|
||||
|
||||
### RPM package 📦
|
||||
|
||||
Get `rpm` package from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp-0.4.2-1.x86_64.rpm)
|
||||
or run `wget https://github.com/veeso/termscp/releases/latest/download/termscp-0.4.2-1.x86_64.rpm`
|
||||
|
||||
then install through rpm:
|
||||
|
||||
```sh
|
||||
rpm -U termscp_*.rpm
|
||||
```
|
||||
|
||||
### AUR Package 🔼
|
||||
|
||||
On Arch Linux based distribution, you can install termscp using for istance [yay](https://github.com/Jguer/yay), which I recommend to install AUR packages.
|
||||
|
||||
```sh
|
||||
yay -S termscp
|
||||
```
|
||||
|
||||
### Chocolatey 🍫
|
||||
|
||||
You can install TermSCP on Windows using [chocolatey](https://chocolatey.org/)
|
||||
|
||||
Start PowerShell as administrator and run
|
||||
|
||||
```ps
|
||||
choco install termscp
|
||||
```
|
||||
|
||||
Alternatively you can download the ZIP file from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp.0.4.2.nupkg)
|
||||
|
||||
and then with PowerShell started with administrator previleges, run:
|
||||
|
||||
```ps
|
||||
choco install termscp -s .
|
||||
```
|
||||
|
||||
### Brew 🍻
|
||||
|
||||
You can install TermSCP on MacOS using [brew](https://brew.sh/)
|
||||
|
||||
From your terminal run
|
||||
|
||||
```sh
|
||||
brew install veeso/termscp/termscp
|
||||
```
|
||||
For more information or other platforms, please visit [veeso.github.io](https://veeso.github.io/termscp/#get-started) to view all installation methods.
|
||||
|
||||
---
|
||||
|
||||
## User Manual 🎓
|
||||
## Buy me a coffee ☕
|
||||
|
||||
[Click here](docs/man.md) to read the user manual!
|
||||
If you like termscp and you'd love to see the project to grow, please consider a little donation 🥳
|
||||
|
||||
What you will find:
|
||||
|
||||
- CLI options
|
||||
- Keybindings
|
||||
- Bookmarks
|
||||
- Configuration
|
||||
[](https://www.buymeacoffee.com/veeso)
|
||||
|
||||
---
|
||||
|
||||
@@ -176,25 +87,21 @@ The developer documentation can be found on Rust Docs at <https://docs.rs/termsc
|
||||
|
||||
## Known issues 🧻
|
||||
|
||||
- `NoSuchFileOrDirectory` on connect (WSL): I know about this issue and it's a glitch of WSL I guess. Don't worry about it, just move the termscp executable into another PATH location, such as `/usr/bin`, or install it through the appropriate package format (e.g. deb).
|
||||
- `NoSuchFileOrDirectory` on connect (WSL1): I know about this issue and it's a glitch of WSL I guess. Don't worry about it, just move the termscp executable into another PATH location, such as `/usr/bin`, or install it through the appropriate package format (e.g. deb).
|
||||
|
||||
---
|
||||
|
||||
## Upcoming Features 🧪
|
||||
|
||||
- **Themes provider 🎨**: I'm still thinking about how I will implement this, but basically the idea is to have a configuration file where it will be possible
|
||||
to define the color schema for the entire application. I haven't planned this release yet
|
||||
- **Local and remote file explorer format 🃏**: From 0.5.0 you will be able to customize the file format for both local and remote hosts.
|
||||
- **Synchronized browsing of local and remote directories ⌚**: See [Issue 8](https://github.com/veeso/termscp/issues/8)
|
||||
- **Group file select 🤩**: Possibility to select a group of files in explorers to operate on
|
||||
Major termscp updates will now be seasonal, so expect 4 major updates during the year.
|
||||
|
||||
No other new feature is planned at the moment. I actually think that termscp is getting mature and now I should focus upcoming updates more on bug fixing and
|
||||
code/performance improvements than on new features.
|
||||
Anyway there are some ideas which I'd like to implement. If you want to start working on them, feel free to open a PR:
|
||||
- **Keyring-rs on Linux 🔐**: Planned for the *summer update*, check for updates in [this issue](https://github.com/veeso/termscp/issues/2)
|
||||
- **Samba Support 🎉**: This will require a long time to be implemented, since I'm thinking of implementing a Rust native samba library from scratch, since I don't want to add new C-bindings. It'll maybe included in the *summer update*.
|
||||
- **Themes provider 🎨**: I'm still thinking about how I will implement this, but basically the idea is to have a configuration file where it will be possible to define the color schema for the entire application. I haven't planned this release yet
|
||||
- **Configuration profile for bookmarks 📚**: I would like to, but I still have to analyze it.
|
||||
- **AWS S3 support 🪣**: There is already a library for AWS S3, but this is really on bottom of my implementation list at the moment, due to interest and I don't really have a system where to test it.
|
||||
|
||||
- Amazon S3 support
|
||||
- Samba support
|
||||
- Themes provider
|
||||
Along to new features, termscp developments is now focused on UI and performance improvements, so if you have any suggestion, feel free to open an issue.
|
||||
|
||||
---
|
||||
|
||||
@@ -209,13 +116,13 @@ Please follow [our contributing guidelines](CONTRIBUTING.md)
|
||||
|
||||
## Changelog ⏳
|
||||
|
||||
View TermSCP's changelog [HERE](CHANGELOG.md)
|
||||
View termscp's changelog [HERE](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## Powered by 🚀
|
||||
## Powered by 💪
|
||||
|
||||
TermSCP is powered by these aweseome projects:
|
||||
termscp is powered by these aweseome projects:
|
||||
|
||||
- [bytesize](https://github.com/hyunsik/bytesize)
|
||||
- [crossterm](https://github.com/crossterm-rs/crossterm)
|
||||
@@ -226,7 +133,9 @@ TermSCP is powered by these aweseome projects:
|
||||
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
|
||||
- [textwrap](https://github.com/mgeisler/textwrap)
|
||||
- [tui-rs](https://github.com/fdehau/tui-rs)
|
||||
- [tui-realm](https://github.com/veeso/tui-realm)
|
||||
- [whoami](https://github.com/libcala/whoami)
|
||||
- [wildmatch](https://github.com/becheran/wildmatch)
|
||||
|
||||
---
|
||||
|
||||
@@ -250,14 +159,6 @@ TermSCP is powered by these aweseome projects:
|
||||
|
||||
---
|
||||
|
||||
## Buy me a coffee ☕
|
||||
|
||||
If you like termscp and you'd love to see the project to grow, please consider a little donation 🥳
|
||||
|
||||
[](https://www.buymeacoffee.com/veeso)
|
||||
|
||||
---
|
||||
|
||||
## License 📃
|
||||
|
||||
termscp is licensed under the MIT license.
|
||||
|
||||
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 472 KiB After Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 2.7 MiB |
6
dist/pkgs/arch/.SRCINFO
vendored
@@ -1,13 +1,13 @@
|
||||
pkgbase = termscp
|
||||
pkgdesc = TermSCP is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal.
|
||||
pkgver = 0.4.2
|
||||
pkgdesc = termscp is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal.
|
||||
pkgver = 0.5.0
|
||||
pkgrel = 1
|
||||
url = https://github.com/veeso/termscp
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
provides = termscp
|
||||
options = strip
|
||||
source = https://github.com/veeso/termscp/releases/download/v0.4.2/termscp-0.4.2-x86_64.tar.gz
|
||||
source = https://github.com/veeso/termscp/releases/download/v0.5.0/termscp-0.5.0-x86_64.tar.gz
|
||||
sha256sums = c72f78a4707402f7f970a883899f4f1583fd9eca6166cb7f7616be97cabf768a
|
||||
|
||||
pkgname = termscp
|
||||
|
||||
4
dist/pkgs/arch/PKGBUILD
vendored
@@ -1,8 +1,8 @@
|
||||
# Maintainer: Christian Visintin
|
||||
pkgname=termscp
|
||||
pkgver=0.4.2
|
||||
pkgver=0.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="TermSCP is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal."
|
||||
pkgdesc="termscp is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal."
|
||||
url="https://github.com/veeso/termscp"
|
||||
license=("MIT")
|
||||
arch=("x86_64")
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
Document audience: developers
|
||||
|
||||
- [Developer Manual](#developer-manual)
|
||||
- [How TermSCP works](#how-termscp-works)
|
||||
- [How termscp works](#how-termscp-works)
|
||||
- [Activities](#activities)
|
||||
- [The Context](#the-context)
|
||||
- [Tests fails due to receivers](#tests-fails-due-to-receivers)
|
||||
- [Implementing File Transfers](#implementing-file-transfers)
|
||||
|
||||
Welcome to the developer manual for TermSCP. This chapter DOESN'T contain the documentation for TermSCP modules, which can instead be found on Rust Docs at <https://docs.rs/termscp>
|
||||
This chapter describes how TermSCP works and the guide lines to implement stuff such as file transfers and add features to the user interface.
|
||||
Welcome to the developer manual for termscp. This chapter DOESN'T contain the documentation for termscp modules, which can instead be found on Rust Docs at <https://docs.rs/termscp>
|
||||
This chapter describes how termscp works and the guide lines to implement stuff such as file transfers and add features to the user interface.
|
||||
|
||||
## How TermSCP works
|
||||
## How termscp works
|
||||
|
||||
TermSCP is basically made up of 4 components:
|
||||
termscp is basically made up of 4 components:
|
||||
|
||||
- the **filetransfer**: the filetransfer takes care of managing the remote file system; it provides function to establish a connection with the remote, operating on the remote server file system (e.g. remove files, make directories, rename files, ...), read files and write files. The FileTransfer, as we'll see later, is actually a trait, and for each protocol a FileTransfer must be implement the trait.
|
||||
- the **host**: the host module provides functions to interact with the local host file system.
|
||||
@@ -70,7 +70,7 @@ Yes. This happens quite often and is related to the fact that I'm using public S
|
||||
|
||||
## Implementing File Transfers
|
||||
|
||||
This chapter describes how to implement a file transfer in TermSCP. A file transfer is a module which implements the `FileTransfer` trait. The file transfer provides different modules to interact with a remote server, which in addition to the most obvious methods, used to download and upload files, provides also methods to list files, delete files, create directories etc.
|
||||
This chapter describes how to implement a file transfer in termscp. A file transfer is a module which implements the `FileTransfer` trait. The file transfer provides different modules to interact with a remote server, which in addition to the most obvious methods, used to download and upload files, provides also methods to list files, delete files, create directories etc.
|
||||
|
||||
In the following steps I will describe how to implement a new file transfer, in this case I will be implementing the SCP file transfer (which I'm actually implementing the moment I'm writing this lines).
|
||||
|
||||
@@ -81,7 +81,7 @@ In the following steps I will describe how to implement a new file transfer, in
|
||||
```rs
|
||||
/// ## FileTransferProtocol
|
||||
///
|
||||
/// This enum defines the different transfer protocol available in TermSCP
|
||||
/// This enum defines the different transfer protocol available in termscp
|
||||
#[derive(std::cmp::PartialEq, std::fmt::Debug, std::clone::Clone)]
|
||||
pub enum FileTransferProtocol {
|
||||
Sftp,
|
||||
|
||||
105
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 <password>` if address is provided, password will be this argument
|
||||
- `-q, --quiet` Disable logging
|
||||
- `-v, --version` Print version info
|
||||
- `-h, --help` Print help page
|
||||
|
||||
TermSCP can be started in two different mode, if no extra arguments is provided, TermSCP will show the authentication form, where the user will be able to provide the parameters required to connect to the remote peer.
|
||||
termscp can be started in two different mode, if no extra arguments is provided, termscp will show the authentication form, where the user will be able to provide the parameters required to connect to the remote peer.
|
||||
|
||||
Alternatively, the user can provide an address as argument to skip the authentication form and starting directly the connection to the remote server.
|
||||
|
||||
@@ -76,7 +62,18 @@ Password can be basically provided through 3 ways when address argument is provi
|
||||
|
||||
---
|
||||
|
||||
## Keybindings ⌨
|
||||
## File explorer 📂
|
||||
|
||||
When we refer to file explorers in termscp, we refer to the panels you can see after establishing a connection with the remote.
|
||||
These panels are basically 3 (yes, three actually):
|
||||
|
||||
- Local explorer panel: it is displayed on the left of your screen and shows the current directory entries for localhost
|
||||
- Remote explorer panel: it is displayed on the right of your screen and shows the current directory entries for the remote host.
|
||||
- Find results panel: depending on where you're searching for files (local/remote) it will replace the local or the explorer panel. This panel shows the entries matching the search query you performed.
|
||||
|
||||
In order to change panel you need to type `<LEFT>` to move the remote explorer panel and `<RIGHT>` to move back to the local explorer panel. Whenever you are in the find results panel, you need to press `<ESC>` to exit panel and go back to the previous panel.
|
||||
|
||||
### Keybindings ⌨
|
||||
|
||||
| Key | Command | Reminder |
|
||||
|---------------|-------------------------------------------------------|-------------|
|
||||
@@ -100,23 +97,45 @@ Password can be basically provided through 3 ways when address argument is provi
|
||||
| `<G>` | Go to supplied path | Go to |
|
||||
| `<H>` | Show help | Help |
|
||||
| `<I>` | Show info about selected file or directory | Info |
|
||||
| `<L>` | Reload current directory's content | List |
|
||||
| `<L>` | Reload current directory's content / Clear selection | List |
|
||||
| `<M>` | Select a file | Mark |
|
||||
| `<N>` | Create new file with provided name | New |
|
||||
| `<O>` | Edit file; see [Text editor](#text-editor-) | Open |
|
||||
| `<Q>` | Quit TermSCP | Quit |
|
||||
| `<O>` | Edit file; see Text editor | Open |
|
||||
| `<Q>` | Quit termscp | Quit |
|
||||
| `<R>` | Rename file | Rename |
|
||||
| `<S>` | Save file as... | Save |
|
||||
| `<U>` | Go to parent directory | Upper |
|
||||
| `<X>` | Execute a command | eXecute |
|
||||
| `<Y>` | Toggle synchronized browsing | sYnc |
|
||||
| `<DEL>` | Delete file | |
|
||||
| `<CTRL+A>` | Select all files | |
|
||||
| `<CTRL+C>` | Abort file transfer process | |
|
||||
|
||||
### Work on multiple files 🥷
|
||||
|
||||
You can opt to work on multiple files, selecting them pressing `<M>`, in order to select the current file, or pressing `<CTRL+A>`, which will select all the files in the working directory.
|
||||
Once a file is marked for selection, it will be displayed with a `*` on the left.
|
||||
When working on selection, only selected file will be processed for actions, while the current highlighted item will be ignored.
|
||||
It is possible to work on multiple files also when in the find result panel.
|
||||
All the actions are available when working with multiple files, but be aware that some actions work in a slightly different way. Let's dive in:
|
||||
|
||||
- *Copy*: whenever you copy a file, you'll be prompted to insert the destination name. When working with multiple file, this name refers to the destination directory where all these files will be copied.
|
||||
- *Rename*: same as copy, but will move files there.
|
||||
- *Save as*: same as copy, but will write them there.
|
||||
|
||||
### Synchronized browsing ⏲️
|
||||
|
||||
When enabled, synchronized browsing, will allow you to synchronize the navigation between the two panels.
|
||||
This means that whenever you'll change the working directory on one panel, the same action will be reproduced on the other panel. If you want to enable synchronized browsing just press `<Y>`; press twice to disable. While enabled, the synchronized browising state will be reported on the status bar on `ON`.
|
||||
|
||||
*Warning*: at the moment, whenever you try to access an unexisting directory, you won't be prompted to create it. This might change in a future update.
|
||||
|
||||
---
|
||||
|
||||
## Bookmarks ⭐
|
||||
|
||||
In TermSCP it is possible to save favourites hosts, which can be then loaded quickly from the main layout of termscp.
|
||||
TermSCP will also save the last 16 hosts you connected to.
|
||||
In termscp it is possible to save favourites hosts, which can be then loaded quickly from the main layout of termscp.
|
||||
termscp will also save the last 16 hosts you connected to.
|
||||
This feature allows you to load all the parameters required to connect to a certain remote, simply selecting the bookmark in the tab under the authentication form.
|
||||
|
||||
Bookmarks will be saved, if possible at:
|
||||
@@ -145,7 +164,7 @@ In order to create a new bookmark, just follow these steps:
|
||||
|
||||
whenever you want to use the previously saved connection, just press `<TAB>` to navigate to the bookmarks list and load the bookmark parameters into the form pressing `<ENTER>`.
|
||||
|
||||

|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,8 +31,8 @@ use crate::host::{HostError, Localhost};
|
||||
use crate::system::config_client::ConfigClient;
|
||||
use crate::system::environment;
|
||||
use crate::ui::activities::{
|
||||
auth_activity::AuthActivity, filetransfer_activity::FileTransferActivity,
|
||||
setup_activity::SetupActivity, Activity, ExitReason,
|
||||
auth::AuthActivity, filetransfer::FileTransferActivity, setup::SetupActivity, Activity,
|
||||
ExitReason,
|
||||
};
|
||||
use crate::ui::context::{Context, FileTransferParams};
|
||||
|
||||
@@ -56,6 +56,7 @@ pub enum NextActivity {
|
||||
pub struct ActivityManager {
|
||||
context: Option<Context>,
|
||||
interval: Duration,
|
||||
local_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ActivityManager {
|
||||
@@ -64,19 +65,19 @@ impl ActivityManager {
|
||||
/// Initializes a new Activity Manager
|
||||
pub fn new(local_dir: &Path, interval: Duration) -> Result<ActivityManager, HostError> {
|
||||
// Prepare Context
|
||||
let host: Localhost = match Localhost::new(local_dir.to_path_buf()) {
|
||||
Ok(h) => h,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// Initialize configuration client
|
||||
let (config_client, error): (Option<ConfigClient>, Option<String>) =
|
||||
match Self::init_config_client() {
|
||||
Ok(cli) => (Some(cli), None),
|
||||
Err(err) => (None, Some(err)),
|
||||
Err(err) => {
|
||||
error!("Failed to initialize config client: {}", err);
|
||||
(None, Some(err))
|
||||
}
|
||||
};
|
||||
let ctx: Context = Context::new(host, config_client, error);
|
||||
let ctx: Context = Context::new(config_client, error);
|
||||
Ok(ActivityManager {
|
||||
context: Some(ctx),
|
||||
local_dir: local_dir.to_path_buf(),
|
||||
interval,
|
||||
})
|
||||
}
|
||||
@@ -133,6 +134,7 @@ impl ActivityManager {
|
||||
/// Returns when activity terminates.
|
||||
/// Returns the next activity to run
|
||||
fn run_authentication(&mut self) -> Option<NextActivity> {
|
||||
info!("Starting AuthActivity...");
|
||||
// Prepare activity
|
||||
let mut activity: AuthActivity = AuthActivity::default();
|
||||
// Prepare result
|
||||
@@ -140,7 +142,10 @@ impl ActivityManager {
|
||||
// Get context
|
||||
let ctx: Context = match self.context.take() {
|
||||
Some(ctx) => ctx,
|
||||
None => return None,
|
||||
None => {
|
||||
error!("Failed to start AuthActivity: context is None");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Create activity
|
||||
activity.on_create(ctx);
|
||||
@@ -151,16 +156,19 @@ impl ActivityManager {
|
||||
if let Some(exit_reason) = activity.will_umount() {
|
||||
match exit_reason {
|
||||
ExitReason::Quit => {
|
||||
info!("AuthActivity terminated due to 'Quit'");
|
||||
result = None;
|
||||
break;
|
||||
}
|
||||
ExitReason::EnterSetup => {
|
||||
// User requested activity
|
||||
info!("AuthActivity terminated due to 'EnterSetup'");
|
||||
result = Some(NextActivity::SetupActivity);
|
||||
break;
|
||||
}
|
||||
ExitReason::Connect => {
|
||||
// User submitted, set next activity
|
||||
info!("AuthActivity terminated due to 'Connect'");
|
||||
result = Some(NextActivity::FileTransfer);
|
||||
break;
|
||||
}
|
||||
@@ -172,6 +180,7 @@ impl ActivityManager {
|
||||
}
|
||||
// Destroy activity
|
||||
self.context = activity.on_destroy();
|
||||
info!("AuthActivity destroyed");
|
||||
result
|
||||
}
|
||||
|
||||
@@ -181,19 +190,35 @@ impl ActivityManager {
|
||||
/// Returns when activity terminates.
|
||||
/// Returns the next activity to run
|
||||
fn run_filetransfer(&mut self) -> Option<NextActivity> {
|
||||
info!("Starting FileTransferActivity");
|
||||
// Get context
|
||||
let ctx: Context = match self.context.take() {
|
||||
let mut ctx: Context = match self.context.take() {
|
||||
Some(ctx) => ctx,
|
||||
None => return None,
|
||||
None => {
|
||||
error!("Failed to start FileTransferActivity: context is None");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// If ft params is None, return None
|
||||
let ft_params: &FileTransferParams = match ctx.ft_params.as_ref() {
|
||||
Some(ft_params) => &ft_params,
|
||||
None => return None,
|
||||
None => {
|
||||
error!("Failed to start FileTransferActivity: file transfer params is None");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Prepare activity
|
||||
let protocol: FileTransferProtocol = ft_params.protocol;
|
||||
let mut activity: FileTransferActivity = FileTransferActivity::new(protocol);
|
||||
let host: Localhost = match Localhost::new(self.local_dir.clone()) {
|
||||
Ok(host) => host,
|
||||
Err(err) => {
|
||||
// Set error in context
|
||||
error!("Failed to initialize localhost: {}", err);
|
||||
ctx.set_error(format!("Could not initialize localhost: {}", err));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let mut activity: FileTransferActivity = FileTransferActivity::new(host, protocol);
|
||||
// Prepare result
|
||||
let result: Option<NextActivity>;
|
||||
// Create activity
|
||||
@@ -205,11 +230,13 @@ impl ActivityManager {
|
||||
if let Some(exit_reason) = activity.will_umount() {
|
||||
match exit_reason {
|
||||
ExitReason::Quit => {
|
||||
info!("FileTransferActivity terminated due to 'Quit'");
|
||||
result = None;
|
||||
break;
|
||||
}
|
||||
ExitReason::Disconnect => {
|
||||
// User disconnected, set next activity to authentication
|
||||
info!("FileTransferActivity terminated due to 'Authentication'");
|
||||
result = Some(NextActivity::Authentication);
|
||||
break;
|
||||
}
|
||||
@@ -235,7 +262,10 @@ impl ActivityManager {
|
||||
// Get context
|
||||
let ctx: Context = match self.context.take() {
|
||||
Some(ctx) => ctx,
|
||||
None => return None,
|
||||
None => {
|
||||
error!("Failed to start SetupActivity: context is None");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// Create activity
|
||||
activity.on_create(ctx);
|
||||
@@ -244,6 +274,7 @@ impl ActivityManager {
|
||||
activity.on_draw();
|
||||
// Check if activity has terminated
|
||||
if let Some(ExitReason::Quit) = activity.will_umount() {
|
||||
info!("SetupActivity terminated due to 'Quit'");
|
||||
break;
|
||||
}
|
||||
// Sleep for ticks
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ pub struct UserInterfaceConfig {
|
||||
pub show_hidden_files: bool,
|
||||
pub check_for_updates: Option<bool>, // @! Since 0.3.3
|
||||
pub group_dirs: Option<String>,
|
||||
pub file_fmt: Option<String>,
|
||||
pub file_fmt: Option<String>, // Refers to local host (for backward compatibility)
|
||||
pub remote_file_fmt: Option<String>, // @! Since 0.5.0
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, std::fmt::Debug)]
|
||||
@@ -92,6 +93,7 @@ impl Default for UserInterfaceConfig {
|
||||
check_for_updates: Some(true),
|
||||
group_dirs: None,
|
||||
file_fmt: None,
|
||||
remote_file_fmt: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +163,7 @@ impl std::fmt::Display for SerializerError {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
@@ -178,6 +181,7 @@ mod tests {
|
||||
check_for_updates: Some(true),
|
||||
group_dirs: Some(String::from("first")),
|
||||
file_fmt: Some(String::from("{NAME}")),
|
||||
remote_file_fmt: Some(String::from("{USER}")),
|
||||
};
|
||||
let cfg: UserConfig = UserConfig {
|
||||
user_interface: ui,
|
||||
@@ -196,6 +200,10 @@ mod tests {
|
||||
assert_eq!(cfg.user_interface.check_for_updates, Some(true));
|
||||
assert_eq!(cfg.user_interface.group_dirs, Some(String::from("first")));
|
||||
assert_eq!(cfg.user_interface.file_fmt, Some(String::from("{NAME}")));
|
||||
assert_eq!(
|
||||
cfg.user_interface.remote_file_fmt,
|
||||
Some(String::from("{USER}"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -218,6 +226,8 @@ mod tests {
|
||||
);
|
||||
assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true);
|
||||
assert_eq!(cfg.remote.ssh_keys.len(), 0);
|
||||
assert!(cfg.user_interface.file_fmt.is_none());
|
||||
assert!(cfg.user_interface.remote_file_fmt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -34,6 +34,7 @@ extern crate regex;
|
||||
|
||||
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
|
||||
use crate::fs::{FsDirectory, FsEntry, FsFile};
|
||||
use crate::utils::fmt::{fmt_time, shadow_password};
|
||||
use crate::utils::parser::{parse_datetime, parse_lstime};
|
||||
|
||||
// Includes
|
||||
@@ -105,6 +106,7 @@ impl FtpFileTransfer {
|
||||
lazy_static! {
|
||||
static ref LS_RE: Regex = Regex::new(r#"^([\-ld])([\-rwxs]{9})\s+(\d+)\s+(\w+)\s+(\w+)\s+(\d+)\s+(\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+(.+)$"#).unwrap();
|
||||
}
|
||||
debug!("Parsing LIST (UNIX) line: '{}'", line);
|
||||
// Apply regex to result
|
||||
match LS_RE.captures(line) {
|
||||
// String matches regex
|
||||
@@ -182,12 +184,12 @@ impl FtpFileTransfer {
|
||||
};
|
||||
// Check if file_name is '.' or '..'
|
||||
if file_name.as_str() == "." || file_name.as_str() == ".." {
|
||||
debug!("File name is {}; ignoring entry", file_name);
|
||||
return Err(());
|
||||
}
|
||||
// Get symlink
|
||||
let symlink: Option<Box<FsEntry>> = match symlink_path {
|
||||
None => None,
|
||||
Some(p) => Some(Box::new(match p.to_string_lossy().ends_with('/') {
|
||||
let symlink: Option<Box<FsEntry>> = symlink_path.map(|p| {
|
||||
Box::new(match p.to_string_lossy().ends_with('/') {
|
||||
true => {
|
||||
// NOTE: is_dir becomes true
|
||||
is_dir = true;
|
||||
@@ -226,8 +228,8 @@ impl FtpFileTransfer {
|
||||
group: gid,
|
||||
unix_pex: Some(unix_pex),
|
||||
}),
|
||||
})),
|
||||
};
|
||||
})
|
||||
});
|
||||
let mut abs_path: PathBuf = PathBuf::from(path);
|
||||
abs_path.push(file_name.as_str());
|
||||
let abs_path: PathBuf = Self::resolve(abs_path.as_path());
|
||||
@@ -237,6 +239,19 @@ impl FtpFileTransfer {
|
||||
.extension()
|
||||
.map(|s| String::from(s.to_string_lossy()));
|
||||
// Return
|
||||
debug!("Follows LIST line '{}' attributes", line);
|
||||
debug!("Is directory? {}", is_dir);
|
||||
debug!("Is symlink? {}", is_symlink);
|
||||
debug!("name: {}", file_name);
|
||||
debug!("abs_path: {}", abs_path.display());
|
||||
debug!("last_change_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("last_access_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("creation_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("symlink: {:?}", symlink);
|
||||
debug!("user: {:?}", uid);
|
||||
debug!("group: {:?}", gid);
|
||||
debug!("unix_pex: {:?}", unix_pex);
|
||||
debug!("---------------------------------------");
|
||||
// Push to entries
|
||||
Ok(match is_dir {
|
||||
true => FsEntry::Directory(FsDirectory {
|
||||
@@ -288,6 +303,7 @@ impl FtpFileTransfer {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
debug!("Parsing LIST (DOS) line: '{}'", line);
|
||||
// Apply regex to result
|
||||
match DOS_RE.captures(line) {
|
||||
// String matches regex
|
||||
@@ -325,6 +341,14 @@ impl FtpFileTransfer {
|
||||
.as_path()
|
||||
.extension()
|
||||
.map(|s| String::from(s.to_string_lossy()));
|
||||
debug!("Follows LIST line '{}' attributes", line);
|
||||
debug!("Is directory? {}", is_dir);
|
||||
debug!("name: {}", file_name);
|
||||
debug!("abs_path: {}", abs_path.display());
|
||||
debug!("last_change_time: {}", fmt_time(time, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("last_access_time: {}", fmt_time(time, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("creation_time: {}", fmt_time(time, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("---------------------------------------");
|
||||
// Return entry
|
||||
Ok(match is_dir {
|
||||
true => FsEntry::Directory(FsDirectory {
|
||||
@@ -383,17 +407,20 @@ impl FileTransfer for FtpFileTransfer {
|
||||
password: Option<String>,
|
||||
) -> Result<Option<String>, FileTransferError> {
|
||||
// Get stream
|
||||
info!("Connecting to {}:{}", address, port);
|
||||
let mut stream: FtpStream = match FtpStream::connect(format!("{}:{}", address, port)) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
error!("Failed to connect: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
err.to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
// If SSL, open secure session
|
||||
if self.ftps {
|
||||
info!("Setting up TLS stream...");
|
||||
let ctx = match TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
@@ -401,19 +428,21 @@ impl FileTransfer for FtpFileTransfer {
|
||||
{
|
||||
Ok(tls) => tls,
|
||||
Err(err) => {
|
||||
error!("Failed to setup TLS stream: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::SslError,
|
||||
err.to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
stream = match stream.into_secure(ctx, address.as_str()) {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
error!("Failed to setup TLS stream: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::SslError,
|
||||
err.to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -426,14 +455,22 @@ impl FileTransfer for FtpFileTransfer {
|
||||
Some(pwd) => pwd,
|
||||
None => String::new(),
|
||||
};
|
||||
info!(
|
||||
"Signin in with username: {}, password: {}",
|
||||
username,
|
||||
shadow_password(password.as_str())
|
||||
);
|
||||
if let Err(err) = stream.login(username.as_str(), password.as_str()) {
|
||||
error!("Login failed: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
debug!("Setting transfer type to Binary");
|
||||
// Initialize file type
|
||||
if let Err(err) = stream.transfer_type(FileType::Binary) {
|
||||
error!("Failed to set transfer type to binary: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
err.to_string(),
|
||||
@@ -441,6 +478,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
}
|
||||
// Set stream
|
||||
self.stream = Some(stream);
|
||||
info!("Connection successfully established");
|
||||
// Return OK
|
||||
Ok(self.stream.as_ref().unwrap().get_welcome_msg())
|
||||
}
|
||||
@@ -450,6 +488,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// Disconnect from the remote server
|
||||
|
||||
fn disconnect(&mut self) -> Result<(), FileTransferError> {
|
||||
info!("Disconnecting from FTP server...");
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.quit() {
|
||||
Ok(_) => Ok(()),
|
||||
@@ -476,6 +515,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// Print working directory
|
||||
|
||||
fn pwd(&mut self) -> Result<PathBuf, FileTransferError> {
|
||||
info!("PWD");
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.pwd() {
|
||||
Ok(path) => Ok(PathBuf::from(path.as_str())),
|
||||
@@ -496,6 +536,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
|
||||
fn change_dir(&mut self, dir: &Path) -> Result<PathBuf, FileTransferError> {
|
||||
let dir: PathBuf = Self::resolve(dir);
|
||||
info!("Changing directory to {}", dir.display());
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.cwd(&dir.as_path().to_string_lossy()) {
|
||||
Ok(_) => Ok(dir),
|
||||
@@ -515,6 +556,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// Copy file to destination
|
||||
fn copy(&mut self, _src: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> {
|
||||
// FTP doesn't support file copy
|
||||
debug!("COPY issues (will fail, since unsupported)");
|
||||
Err(FileTransferError::new(
|
||||
FileTransferErrorType::UnsupportedFeature,
|
||||
))
|
||||
@@ -526,9 +568,11 @@ impl FileTransfer for FtpFileTransfer {
|
||||
|
||||
fn list_dir(&mut self, path: &Path) -> Result<Vec<FsEntry>, FileTransferError> {
|
||||
let dir: PathBuf = Self::resolve(path);
|
||||
info!("LIST dir {}", dir.display());
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.list(Some(&dir.as_path().to_string_lossy())) {
|
||||
Ok(entries) => {
|
||||
debug!("Got {} lines in LIST result", entries.len());
|
||||
// Prepare result
|
||||
let mut result: Vec<FsEntry> = Vec::with_capacity(entries.len());
|
||||
// Iterate over entries
|
||||
@@ -537,6 +581,11 @@ impl FileTransfer for FtpFileTransfer {
|
||||
result.push(file);
|
||||
}
|
||||
}
|
||||
debug!(
|
||||
"{} out of {} were valid entries",
|
||||
result.len(),
|
||||
entries.len()
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -555,6 +604,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// Make directory
|
||||
fn mkdir(&mut self, dir: &Path) -> Result<(), FileTransferError> {
|
||||
let dir: PathBuf = Self::resolve(dir);
|
||||
info!("MKDIR {}", dir.display());
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.mkdir(&dir.as_path().to_string_lossy()) {
|
||||
Ok(_) => Ok(()),
|
||||
@@ -578,9 +628,11 @@ impl FileTransfer for FtpFileTransfer {
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
));
|
||||
}
|
||||
info!("Removing entry {}", fsentry.get_abs_path().display());
|
||||
match fsentry {
|
||||
// Match fs entry...
|
||||
FsEntry::File(file) => {
|
||||
debug!("entry is a file; removing file");
|
||||
// Remove file directly
|
||||
match self.stream.as_mut().unwrap().rm(file.name.as_ref()) {
|
||||
Ok(_) => Ok(()),
|
||||
@@ -592,9 +644,11 @@ impl FileTransfer for FtpFileTransfer {
|
||||
}
|
||||
FsEntry::Directory(dir) => {
|
||||
// Get directory files
|
||||
debug!("Entry is a directory; iterating directory entries");
|
||||
match self.list_dir(dir.abs_path.as_path()) {
|
||||
Ok(files) => {
|
||||
// Remove recursively files
|
||||
debug!("Removing {} entries from directory...", files.len());
|
||||
for file in files.iter() {
|
||||
if let Err(err) = self.remove(&file) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
@@ -604,6 +658,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
}
|
||||
}
|
||||
// Once all files in directory have been deleted, remove directory
|
||||
debug!("Finally removing directory {}", dir.name);
|
||||
match self.stream.as_mut().unwrap().rmdir(dir.name.as_str()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -626,6 +681,11 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// Rename file or a directory
|
||||
fn rename(&mut self, file: &FsEntry, dst: &Path) -> Result<(), FileTransferError> {
|
||||
let dst: PathBuf = Self::resolve(dst);
|
||||
info!(
|
||||
"Renaming {} to {}",
|
||||
file.get_abs_path().display(),
|
||||
dst.display()
|
||||
);
|
||||
match &mut self.stream {
|
||||
Some(stream) => {
|
||||
// Get name
|
||||
@@ -692,6 +752,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
file_name: &Path,
|
||||
) -> Result<Box<dyn Write>, FileTransferError> {
|
||||
let file_name: PathBuf = Self::resolve(file_name);
|
||||
info!("Sending file {}", file_name.display());
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.put_with_stream(&file_name.as_path().to_string_lossy()) {
|
||||
Ok(writer) => Ok(Box::new(writer)), // NOTE: don't use BufWriter here, since already returned by the library
|
||||
@@ -711,6 +772,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// Receive file from remote with provided name
|
||||
/// Returns file and its size
|
||||
fn recv_file(&mut self, file: &FsFile) -> Result<Box<dyn Read>, FileTransferError> {
|
||||
info!("Receiving file {}", file.abs_path.display());
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.get(&file.abs_path.as_path().to_string_lossy()) {
|
||||
Ok(reader) => Ok(Box::new(reader)), // NOTE: don't use BufReader here, since already returned by the library
|
||||
@@ -733,6 +795,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// This is necessary for some protocols such as FTP.
|
||||
/// You must call this method each time you want to finalize the write of the remote file.
|
||||
fn on_sent(&mut self, writable: Box<dyn Write>) -> Result<(), FileTransferError> {
|
||||
info!("Finalizing put stream");
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.finalize_put_stream(writable) {
|
||||
Ok(_) => Ok(()),
|
||||
@@ -755,6 +818,7 @@ impl FileTransfer for FtpFileTransfer {
|
||||
/// This mighe be necessary for some protocols.
|
||||
/// You must call this method each time you want to finalize the read of the remote file.
|
||||
fn on_recv(&mut self, readable: Box<dyn Read>) -> Result<(), FileTransferError> {
|
||||
info!("Finalizing get");
|
||||
match &mut self.stream {
|
||||
Some(stream) => match stream.finalize_get(readable) {
|
||||
Ok(_) => Ok(()),
|
||||
@@ -775,6 +839,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::utils::fmt::fmt_time;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -41,7 +41,7 @@ pub mod sftp_transfer;
|
||||
|
||||
/// ## FileTransferProtocol
|
||||
///
|
||||
/// This enum defines the different transfer protocol available in TermSCP
|
||||
/// This enum defines the different transfer protocol available in termscp
|
||||
|
||||
#[derive(PartialEq, std::fmt::Debug, std::clone::Clone, Copy)]
|
||||
pub enum FileTransferProtocol {
|
||||
@@ -59,11 +59,19 @@ pub struct FileTransferError {
|
||||
msg: Option<String>,
|
||||
}
|
||||
|
||||
impl FileTransferError {
|
||||
/// ### kind
|
||||
///
|
||||
/// Returns the error kind
|
||||
pub fn kind(&self) -> FileTransferErrorType {
|
||||
self.code
|
||||
}
|
||||
}
|
||||
|
||||
/// ## FileTransferErrorType
|
||||
///
|
||||
/// FileTransferErrorType defines the possible errors available for a file transfer
|
||||
#[allow(dead_code)]
|
||||
#[derive(Error, Debug)]
|
||||
#[derive(Error, Debug, Clone, Copy, PartialEq)]
|
||||
pub enum FileTransferErrorType {
|
||||
#[error("Authentication failed")]
|
||||
AuthenticationFailed,
|
||||
@@ -77,8 +85,6 @@ pub enum FileTransferErrorType {
|
||||
DirStatFailed,
|
||||
#[error("Failed to create file")]
|
||||
FileCreateDenied,
|
||||
#[error("IO error: {0}")]
|
||||
IoErr(std::io::Error),
|
||||
#[error("No such file or directory")]
|
||||
NoSuchFileOrDirectory,
|
||||
#[error("Not enough permissions")]
|
||||
@@ -313,14 +319,14 @@ impl std::string::ToString for FileTransferProtocol {
|
||||
}
|
||||
|
||||
impl std::str::FromStr for FileTransferProtocol {
|
||||
type Err = ();
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"FTP" => Ok(FileTransferProtocol::Ftp(false)),
|
||||
"FTPS" => Ok(FileTransferProtocol::Ftp(true)),
|
||||
"SCP" => Ok(FileTransferProtocol::Scp),
|
||||
"SFTP" => Ok(FileTransferProtocol::Sftp),
|
||||
_ => Err(()),
|
||||
_ => Err(s.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,6 +338,7 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::str::FromStr;
|
||||
use std::string::ToString;
|
||||
|
||||
@@ -396,13 +403,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_filetransfer_mod_error() {
|
||||
let err: FileTransferError = FileTransferError::new_ex(
|
||||
FileTransferErrorType::IoErr(std::io::Error::from(std::io::ErrorKind::AddrInUse)),
|
||||
FileTransferErrorType::NoSuchFileOrDirectory,
|
||||
String::from("non va una mazza"),
|
||||
);
|
||||
assert_eq!(*err.msg.as_ref().unwrap(), String::from("non va una mazza"));
|
||||
assert_eq!(
|
||||
format!("{}", err),
|
||||
String::from("IO error: address in use (non va una mazza)")
|
||||
String::from("No such file or directory (non va una mazza)")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
@@ -481,5 +488,7 @@ mod tests {
|
||||
),
|
||||
String::from("Unsupported feature")
|
||||
);
|
||||
let err = FileTransferError::new(FileTransferErrorType::UnsupportedFeature);
|
||||
assert_eq!(err.kind(), FileTransferErrorType::UnsupportedFeature);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ extern crate ssh2;
|
||||
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
|
||||
use crate::fs::{FsDirectory, FsEntry, FsFile};
|
||||
use crate::system::sshkey_storage::SshKeyStorage;
|
||||
use crate::utils::fmt::{fmt_time, shadow_password};
|
||||
use crate::utils::parser::parse_lstime;
|
||||
|
||||
// Includes
|
||||
@@ -90,6 +91,7 @@ impl ScpFileTransfer {
|
||||
lazy_static! {
|
||||
static ref LS_RE: Regex = Regex::new(r#"^([\-ld])([\-rwxs]{9})\s+(\d+)\s+(\w+)\s+(\w+)\s+(\d+)\s+(\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+(.+)$"#).unwrap();
|
||||
}
|
||||
debug!("Parsing LS line: '{}'", line);
|
||||
// Apply regex to result
|
||||
match LS_RE.captures(line) {
|
||||
// String matches regex
|
||||
@@ -167,6 +169,7 @@ impl ScpFileTransfer {
|
||||
};
|
||||
// Check if file_name is '.' or '..'
|
||||
if file_name.as_str() == "." || file_name.as_str() == ".." {
|
||||
debug!("File name is {}; ignoring entry", file_name);
|
||||
return Err(());
|
||||
}
|
||||
// Get symlink; PATH mustn't be equal to filename
|
||||
@@ -200,6 +203,19 @@ impl ScpFileTransfer {
|
||||
.extension()
|
||||
.map(|s| String::from(s.to_string_lossy()));
|
||||
// Return
|
||||
debug!("Follows LS line '{}' attributes", line);
|
||||
debug!("Is directory? {}", is_dir);
|
||||
debug!("Is symlink? {}", is_symlink);
|
||||
debug!("name: {}", file_name);
|
||||
debug!("abs_path: {}", abs_path.display());
|
||||
debug!("last_change_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("last_access_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("creation_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("symlink: {:?}", symlink);
|
||||
debug!("user: {:?}", uid);
|
||||
debug!("group: {:?}", gid);
|
||||
debug!("unix_pex: {:?}", unix_pex);
|
||||
debug!("---------------------------------------");
|
||||
// Push to entries
|
||||
Ok(match is_dir {
|
||||
true => FsEntry::Directory(FsDirectory {
|
||||
@@ -262,6 +278,7 @@ impl ScpFileTransfer {
|
||||
fn perform_shell_cmd(&mut self, cmd: &str) -> Result<String, FileTransferError> {
|
||||
match self.session.as_mut() {
|
||||
Some(session) => {
|
||||
debug!("Running command: {}", cmd);
|
||||
// Create channel
|
||||
let mut channel: Channel = match session.channel_session() {
|
||||
Ok(ch) => ch,
|
||||
@@ -285,6 +302,7 @@ impl ScpFileTransfer {
|
||||
Ok(_) => {
|
||||
// Wait close
|
||||
let _ = channel.wait_close();
|
||||
debug!("Command output: {}", output);
|
||||
Ok(output)
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -312,6 +330,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
password: Option<String>,
|
||||
) -> Result<Option<String>, FileTransferError> {
|
||||
// Setup tcp stream
|
||||
info!("Connecting to {}:{}", address, port);
|
||||
let socket_addresses: Vec<SocketAddr> =
|
||||
match format!("{}:{}", address, port).to_socket_addrs() {
|
||||
Ok(s) => s.collect(),
|
||||
@@ -325,8 +344,10 @@ impl FileTransfer for ScpFileTransfer {
|
||||
let mut tcp: Option<TcpStream> = None;
|
||||
// Try addresses
|
||||
for socket_addr in socket_addresses.iter() {
|
||||
debug!("Trying socket address {}", socket_addr);
|
||||
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
|
||||
Ok(stream) => {
|
||||
debug!("{} succeded", socket_addr);
|
||||
tcp = Some(stream);
|
||||
break;
|
||||
}
|
||||
@@ -337,26 +358,30 @@ impl FileTransfer for ScpFileTransfer {
|
||||
let tcp: TcpStream = match tcp {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!("No suitable socket address found; connection timeout");
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
String::from("Connection timeout"),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
// Create session
|
||||
let mut session: Session = match Session::new() {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
error!("Could not create session: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
err.to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
// Set TCP stream
|
||||
session.set_tcp_stream(tcp);
|
||||
// Open connection
|
||||
debug!("Initializing handshake");
|
||||
if let Err(err) = session.handshake() {
|
||||
error!("Handshake failed: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
err.to_string(),
|
||||
@@ -372,6 +397,11 @@ impl FileTransfer for ScpFileTransfer {
|
||||
.resolve(address.as_str(), username.as_str())
|
||||
{
|
||||
Some(rsa_key) => {
|
||||
debug!(
|
||||
"Authenticating with user {} and RSA key {}",
|
||||
username,
|
||||
rsa_key.display()
|
||||
);
|
||||
// Authenticate with RSA key
|
||||
if let Err(err) = session.userauth_pubkey_file(
|
||||
username.as_str(),
|
||||
@@ -379,6 +409,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
rsa_key.as_path(),
|
||||
password.as_deref(),
|
||||
) {
|
||||
error!("Authentication failed: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
@@ -387,10 +418,16 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
None => {
|
||||
// Proceeed with username/password authentication
|
||||
debug!(
|
||||
"Authenticating with username {} and password {}",
|
||||
username,
|
||||
shadow_password(password.as_deref().unwrap_or(""))
|
||||
);
|
||||
if let Err(err) = session.userauth_password(
|
||||
username.as_str(),
|
||||
password.unwrap_or_else(|| String::from("")).as_str(),
|
||||
) {
|
||||
error!("Authentication failed: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
@@ -400,13 +437,22 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
// Get banner
|
||||
let banner: Option<String> = session.banner().map(String::from);
|
||||
debug!(
|
||||
"Connection established: {}",
|
||||
banner.as_deref().unwrap_or("")
|
||||
);
|
||||
// Set session
|
||||
self.session = Some(session);
|
||||
// Get working directory
|
||||
debug!("Getting working directory...");
|
||||
match self.perform_shell_cmd("pwd") {
|
||||
Ok(output) => self.wrkdir = PathBuf::from(output.as_str().trim()),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
info!(
|
||||
"Connection established; working directory: {}",
|
||||
self.wrkdir.display()
|
||||
);
|
||||
Ok(banner)
|
||||
}
|
||||
|
||||
@@ -414,6 +460,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
///
|
||||
/// Disconnect from the remote server
|
||||
fn disconnect(&mut self) -> Result<(), FileTransferError> {
|
||||
info!("Disconnecting from remote...");
|
||||
match self.session.as_ref() {
|
||||
Some(session) => {
|
||||
// Disconnect (greet server with 'Mandi' as they do in Friuli)
|
||||
@@ -447,6 +494,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
/// Print working directory
|
||||
|
||||
fn pwd(&mut self) -> Result<PathBuf, FileTransferError> {
|
||||
info!("PWD: {}", self.wrkdir.display());
|
||||
match self.is_connected() {
|
||||
true => Ok(self.wrkdir.clone()),
|
||||
false => Err(FileTransferError::new(
|
||||
@@ -471,6 +519,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
Self::resolve(p.as_path())
|
||||
}
|
||||
};
|
||||
info!("Changing working directory to {}", remote_path.display());
|
||||
// Change directory
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_path(),
|
||||
@@ -484,6 +533,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
true => {
|
||||
// Set working directory
|
||||
self.wrkdir = PathBuf::from(&output.as_str()[1..].trim());
|
||||
info!("Changed working directory to {}", self.wrkdir.display());
|
||||
Ok(self.wrkdir.clone())
|
||||
}
|
||||
false => Err(FileTransferError::new_ex(
|
||||
@@ -512,6 +562,11 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let dst: PathBuf = Self::resolve(dst);
|
||||
info!(
|
||||
"Copying {} to {}",
|
||||
src.get_abs_path().display(),
|
||||
dst.display()
|
||||
);
|
||||
// Run `cp -rf`
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
@@ -555,6 +610,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
// Send ls -l to path
|
||||
info!("Getting file entries in {}", path.display());
|
||||
let path: PathBuf = Self::resolve(path);
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
@@ -572,6 +628,11 @@ impl FileTransfer for ScpFileTransfer {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"Found {} out of {} valid file entries",
|
||||
entries.len(),
|
||||
lines.len()
|
||||
);
|
||||
Ok(entries)
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -594,6 +655,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let dir: PathBuf = Self::resolve(dir);
|
||||
info!("Making directory {}", dir.display());
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
// Mkdir dir && echo 0
|
||||
match self.perform_shell_cmd_with_path(
|
||||
@@ -632,6 +694,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
true => {
|
||||
// Get path
|
||||
let path: PathBuf = file.get_abs_path();
|
||||
info!("Removing file {}", path.display());
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_path(),
|
||||
@@ -669,6 +732,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
// Get path
|
||||
let dst: PathBuf = Self::resolve(dst);
|
||||
let path: PathBuf = file.get_abs_path();
|
||||
info!("Renaming {} to {}", path.display(), dst.display());
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_path(),
|
||||
@@ -717,6 +781,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
info!("Stat {}", path.display());
|
||||
// make command; Directories require `-d` option
|
||||
let cmd: String = match path.to_string_lossy().ends_with('/') {
|
||||
true => format!("ls -ld \"{}\"", path.display()),
|
||||
@@ -729,7 +794,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
Some(p) => PathBuf::from(p),
|
||||
None => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::UnsupportedFeature,
|
||||
FileTransferErrorType::DirStatFailed,
|
||||
String::from("Path has no parent"),
|
||||
))
|
||||
}
|
||||
@@ -760,6 +825,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
info!("Executing command {}", cmd);
|
||||
match self.perform_shell_cmd_with_path(p.as_path(), cmd) {
|
||||
Ok(output) => Ok(output),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -788,7 +854,13 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.session.as_ref() {
|
||||
Some(session) => {
|
||||
let file_name: PathBuf = Self::resolve(file_name);
|
||||
info!(
|
||||
"Sending file {} to {}",
|
||||
local.abs_path.display(),
|
||||
file_name.display()
|
||||
);
|
||||
// Set blocking to true
|
||||
debug!("blocking channel...");
|
||||
session.set_blocking(true);
|
||||
// Calculate file mode
|
||||
let mode: i32 = match local.unix_pex {
|
||||
@@ -818,6 +890,10 @@ impl FileTransfer for ScpFileTransfer {
|
||||
Ok(metadata) => metadata.len(),
|
||||
Err(_) => local.size as u64, // NOTE: fallback to fsentry size
|
||||
};
|
||||
debug!(
|
||||
"File mode {:?}; mtime: {}, atime: {}; file size: {}",
|
||||
mode, times.0, times.1, file_size
|
||||
);
|
||||
// Send file
|
||||
match session.scp_send(file_name.as_path(), mode, file_size, Some(times)) {
|
||||
Ok(channel) => Ok(Box::new(BufWriter::with_capacity(65536, channel))),
|
||||
@@ -840,7 +916,9 @@ impl FileTransfer for ScpFileTransfer {
|
||||
fn recv_file(&mut self, file: &FsFile) -> Result<Box<dyn Read>, FileTransferError> {
|
||||
match self.session.as_ref() {
|
||||
Some(session) => {
|
||||
info!("Receiving file {}", file.abs_path.display());
|
||||
// Set blocking to true
|
||||
debug!("Set blocking...");
|
||||
session.set_blocking(true);
|
||||
match session.scp_recv(file.abs_path.as_path()) {
|
||||
Ok(reader) => Ok(Box::new(BufReader::with_capacity(65536, reader.0))),
|
||||
@@ -885,6 +963,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_new() {
|
||||
|
||||
@@ -32,6 +32,7 @@ extern crate ssh2;
|
||||
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
|
||||
use crate::fs::{FsDirectory, FsEntry, FsFile};
|
||||
use crate::system::sshkey_storage::SshKeyStorage;
|
||||
use crate::utils::fmt::{fmt_time, shadow_password};
|
||||
|
||||
// Includes
|
||||
use ssh2::{Channel, FileStat, OpenFlags, OpenType, Session, Sftp};
|
||||
@@ -159,6 +160,19 @@ impl SftpFileTransfer {
|
||||
}
|
||||
false => None,
|
||||
};
|
||||
debug!("Follows {} attributes", path.display());
|
||||
debug!("Is directory? {}", metadata.is_dir());
|
||||
debug!("Is symlink? {}", is_symlink);
|
||||
debug!("name: {}", file_name);
|
||||
debug!("abs_path: {}", path.display());
|
||||
debug!("last_change_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("last_access_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("creation_time: {}", fmt_time(mtime, "%Y-%m-%dT%H:%M:%S"));
|
||||
debug!("symlink: {:?}", symlink);
|
||||
debug!("user: {:?}", uid);
|
||||
debug!("group: {:?}", gid);
|
||||
debug!("unix_pex: {:?}", pex);
|
||||
debug!("---------------------------------------");
|
||||
// Is a directory?
|
||||
match metadata.is_dir() {
|
||||
true => FsEntry::Directory(FsDirectory {
|
||||
@@ -205,6 +219,7 @@ impl SftpFileTransfer {
|
||||
match self.session.as_mut() {
|
||||
Some(session) => {
|
||||
// Create channel
|
||||
debug!("Running command: {}", cmd);
|
||||
let mut channel: Channel = match session.channel_session() {
|
||||
Ok(ch) => ch,
|
||||
Err(err) => {
|
||||
@@ -227,6 +242,7 @@ impl SftpFileTransfer {
|
||||
Ok(_) => {
|
||||
// Wait close
|
||||
let _ = channel.wait_close();
|
||||
debug!("Command output: {}", output);
|
||||
Ok(output)
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -254,6 +270,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
password: Option<String>,
|
||||
) -> Result<Option<String>, FileTransferError> {
|
||||
// Setup tcp stream
|
||||
info!("Connecting to {}:{}", address, port);
|
||||
let socket_addresses: Vec<SocketAddr> =
|
||||
match format!("{}:{}", address, port).to_socket_addrs() {
|
||||
Ok(s) => s.collect(),
|
||||
@@ -267,6 +284,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
let mut tcp: Option<TcpStream> = None;
|
||||
// Try addresses
|
||||
for socket_addr in socket_addresses.iter() {
|
||||
debug!("Trying socket address {}", socket_addr);
|
||||
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
|
||||
Ok(stream) => {
|
||||
tcp = Some(stream);
|
||||
@@ -279,26 +297,30 @@ impl FileTransfer for SftpFileTransfer {
|
||||
let tcp: TcpStream = match tcp {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
error!("No suitable socket address found; connection timeout");
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
String::from("Connection timeout"),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
// Create session
|
||||
let mut session: Session = match Session::new() {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
error!("Could not create session: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
err.to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
// Set TCP stream
|
||||
session.set_tcp_stream(tcp);
|
||||
// Open connection
|
||||
debug!("Initializing handshake");
|
||||
if let Err(err) = session.handshake() {
|
||||
error!("Handshake failed: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
err.to_string(),
|
||||
@@ -314,6 +336,11 @@ impl FileTransfer for SftpFileTransfer {
|
||||
.resolve(address.as_str(), username.as_str())
|
||||
{
|
||||
Some(rsa_key) => {
|
||||
debug!(
|
||||
"Authenticating with user {} and RSA key {}",
|
||||
username,
|
||||
rsa_key.display()
|
||||
);
|
||||
// Authenticate with RSA key
|
||||
if let Err(err) = session.userauth_pubkey_file(
|
||||
username.as_str(),
|
||||
@@ -321,6 +348,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
rsa_key.as_path(),
|
||||
password.as_deref(),
|
||||
) {
|
||||
error!("Authentication failed: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
@@ -329,10 +357,16 @@ impl FileTransfer for SftpFileTransfer {
|
||||
}
|
||||
None => {
|
||||
// Proceeed with username/password authentication
|
||||
debug!(
|
||||
"Authenticating with username {} and password {}",
|
||||
username,
|
||||
shadow_password(password.as_deref().unwrap_or(""))
|
||||
);
|
||||
if let Err(err) = session.userauth_password(
|
||||
username.as_str(),
|
||||
password.unwrap_or_else(|| String::from("")).as_str(),
|
||||
) {
|
||||
error!("Authentication failed: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
@@ -343,16 +377,19 @@ impl FileTransfer for SftpFileTransfer {
|
||||
// Set blocking to true
|
||||
session.set_blocking(true);
|
||||
// Get Sftp client
|
||||
debug!("Getting SFTP client...");
|
||||
let sftp: Sftp = match session.sftp() {
|
||||
Ok(s) => s,
|
||||
Err(err) => {
|
||||
error!("Could not get sftp client: {}", err);
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
err.to_string(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
// Get working directory
|
||||
debug!("Getting working directory...");
|
||||
self.wrkdir = match sftp.realpath(PathBuf::from(".").as_path()) {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
@@ -367,6 +404,11 @@ impl FileTransfer for SftpFileTransfer {
|
||||
self.session = Some(session);
|
||||
// Set sftp
|
||||
self.sftp = Some(sftp);
|
||||
info!(
|
||||
"Connection established: {}; working directory {}",
|
||||
banner.as_deref().unwrap_or(""),
|
||||
self.wrkdir.display()
|
||||
);
|
||||
Ok(banner)
|
||||
}
|
||||
|
||||
@@ -374,6 +416,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
///
|
||||
/// Disconnect from the remote server
|
||||
fn disconnect(&mut self) -> Result<(), FileTransferError> {
|
||||
info!("Disconnecting from remote...");
|
||||
match self.session.as_ref() {
|
||||
Some(session) => {
|
||||
// Disconnect (greet server with 'Mandi' as they do in Friuli)
|
||||
@@ -407,6 +450,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
///
|
||||
/// Print working directory
|
||||
fn pwd(&mut self) -> Result<PathBuf, FileTransferError> {
|
||||
info!("PWD: {}", self.wrkdir.display());
|
||||
match self.sftp {
|
||||
Some(_) => Ok(self.wrkdir.clone()),
|
||||
None => Err(FileTransferError::new(
|
||||
@@ -426,6 +470,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(p) => p,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
info!("Changed working directory to {}", self.wrkdir.display());
|
||||
Ok(self.wrkdir.clone())
|
||||
}
|
||||
None => Err(FileTransferError::new(
|
||||
@@ -437,11 +482,47 @@ impl FileTransfer for SftpFileTransfer {
|
||||
/// ### copy
|
||||
///
|
||||
/// Copy file to destination
|
||||
fn copy(&mut self, _src: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> {
|
||||
// SFTP doesn't support file copy
|
||||
Err(FileTransferError::new(
|
||||
FileTransferErrorType::UnsupportedFeature,
|
||||
))
|
||||
fn copy(&mut self, src: &FsEntry, dst: &Path) -> Result<(), FileTransferError> {
|
||||
// NOTE: use SCP command to perform copy (UNSAFE)
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let dst: PathBuf = self.get_abs_path(dst);
|
||||
info!(
|
||||
"Copying {} to {}",
|
||||
src.get_abs_path().display(),
|
||||
dst.display()
|
||||
);
|
||||
// Run `cp -rf`
|
||||
match self.perform_shell_cmd_with_path(
|
||||
format!(
|
||||
"cp -rf \"{}\" \"{}\"; echo $?",
|
||||
src.get_abs_path().display(),
|
||||
dst.display()
|
||||
)
|
||||
.as_str(),
|
||||
) {
|
||||
Ok(output) =>
|
||||
// Check if output is 0
|
||||
{
|
||||
match output.as_str().trim() == "0" {
|
||||
true => Ok(()), // File copied
|
||||
false => Err(FileTransferError::new_ex(
|
||||
// Could not copy file
|
||||
FileTransferErrorType::FileCreateDenied,
|
||||
format!("\"{}\"", dst.display()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
false => Err(FileTransferError::new(
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### list_dir
|
||||
@@ -455,6 +536,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(p) => p,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
info!("Getting file entries in {}", path.display());
|
||||
// Get files
|
||||
match sftp.readdir(dir.as_path()) {
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -486,6 +568,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Some(sftp) => {
|
||||
// Make directory
|
||||
let path: PathBuf = self.get_abs_path(PathBuf::from(dir).as_path());
|
||||
info!("Making directory {}", path.display());
|
||||
match sftp.mkdir(path.as_path(), 0o775) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
@@ -510,6 +593,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
));
|
||||
}
|
||||
// Match if file is a file or a directory
|
||||
info!("Removing file {}", file.get_abs_path().display());
|
||||
match file {
|
||||
FsEntry::File(f) => {
|
||||
// Remove file
|
||||
@@ -523,6 +607,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
}
|
||||
FsEntry::Directory(d) => {
|
||||
// Remove recursively
|
||||
debug!("{} is a directory; removing all directory entries", d.name);
|
||||
// Get directory files
|
||||
let directory_content: Vec<FsEntry> = match self.list_dir(d.abs_path.as_path()) {
|
||||
Ok(entries) => entries,
|
||||
@@ -554,6 +639,11 @@ impl FileTransfer for SftpFileTransfer {
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
)),
|
||||
Some(sftp) => {
|
||||
info!(
|
||||
"Moving {} to {}",
|
||||
file.get_abs_path().display(),
|
||||
dst.display()
|
||||
);
|
||||
// Resolve destination path
|
||||
let abs_dst: PathBuf = self.get_abs_path(dst);
|
||||
// Get abs path of entry
|
||||
@@ -580,6 +670,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(p) => p,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
info!("Stat file {}", dir.display());
|
||||
// Get file
|
||||
match sftp.stat(dir.as_path()) {
|
||||
Ok(metadata) => Ok(self.make_fsentry(dir.as_path(), &metadata)),
|
||||
@@ -599,6 +690,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
///
|
||||
/// Execute a command on remote host
|
||||
fn exec(&mut self, cmd: &str) -> Result<String, FileTransferError> {
|
||||
info!("Executing command {}", cmd);
|
||||
match self.is_connected() {
|
||||
true => match self.perform_shell_cmd_with_path(cmd) {
|
||||
Ok(output) => Ok(output),
|
||||
@@ -629,14 +721,20 @@ impl FileTransfer for SftpFileTransfer {
|
||||
)),
|
||||
Some(sftp) => {
|
||||
let remote_path: PathBuf = self.get_abs_path(file_name);
|
||||
info!(
|
||||
"Sending file {} to {}",
|
||||
local.abs_path.display(),
|
||||
remote_path.display()
|
||||
);
|
||||
// Calculate file mode
|
||||
let mode: i32 = match local.unix_pex {
|
||||
None => 0o644,
|
||||
Some((u, g, o)) => ((u as i32) << 6) + ((g as i32) << 3) + (o as i32),
|
||||
};
|
||||
debug!("File mode {:?}", mode);
|
||||
match sftp.open_mode(
|
||||
remote_path.as_path(),
|
||||
OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::APPEND | OpenFlags::TRUNCATE,
|
||||
OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE,
|
||||
mode,
|
||||
OpenType::File,
|
||||
) {
|
||||
@@ -664,6 +762,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(p) => p,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
info!("Receiving file {}", remote_path.display());
|
||||
// Open remote file
|
||||
match sftp.open(remote_path.as_path()) {
|
||||
Ok(file) => Ok(Box::new(BufReader::with_capacity(65536, file))),
|
||||
@@ -702,6 +801,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_new() {
|
||||
let client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ impl FsEntry {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_fs_fsentry_dir() {
|
||||
|
||||
216
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<std::io::Error>,
|
||||
@@ -125,12 +125,17 @@ impl Localhost {
|
||||
///
|
||||
/// Instantiates a new Localhost struct
|
||||
pub fn new(wrkdir: PathBuf) -> Result<Localhost, HostError> {
|
||||
debug!("Initializing localhost at {}", wrkdir.display());
|
||||
let mut host: Localhost = Localhost {
|
||||
wrkdir,
|
||||
files: Vec::new(),
|
||||
};
|
||||
// Check if dir exists
|
||||
if !host.file_exists(host.wrkdir.as_path()) {
|
||||
error!(
|
||||
"Failed to initialize localhost: {} doesn't exist",
|
||||
host.wrkdir.display()
|
||||
);
|
||||
return Err(HostError::new(
|
||||
HostErrorType::NoSuchFileOrDirectory,
|
||||
None,
|
||||
@@ -140,8 +145,15 @@ impl Localhost {
|
||||
// Retrieve files for provided path
|
||||
host.files = match host.scan_dir(host.wrkdir.as_path()) {
|
||||
Ok(files) => files,
|
||||
Err(err) => return Err(err),
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to initialize localhost: could not scan wrkdir: {}",
|
||||
err
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
info!("Localhost initialized with success");
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
@@ -165,8 +177,10 @@ impl Localhost {
|
||||
/// Change working directory with the new provided directory
|
||||
pub fn change_wrkdir(&mut self, new_dir: &Path) -> Result<PathBuf, HostError> {
|
||||
let new_dir: PathBuf = self.to_abs_path(new_dir);
|
||||
info!("Changing localhost directory to {}...", new_dir.display());
|
||||
// Check whether directory exists
|
||||
if !self.file_exists(new_dir.as_path()) {
|
||||
error!("Could not change directory: No such file or directory");
|
||||
return Err(HostError::new(
|
||||
HostErrorType::NoSuchFileOrDirectory,
|
||||
None,
|
||||
@@ -174,10 +188,11 @@ impl Localhost {
|
||||
));
|
||||
}
|
||||
// Change directory
|
||||
if std::env::set_current_dir(new_dir.as_path()).is_err() {
|
||||
if let Err(err) = std::env::set_current_dir(new_dir.as_path()) {
|
||||
error!("Could not enter directory: {}", err);
|
||||
return Err(HostError::new(
|
||||
HostErrorType::NoSuchFileOrDirectory,
|
||||
None,
|
||||
Some(err),
|
||||
new_dir.as_path(),
|
||||
));
|
||||
}
|
||||
@@ -189,11 +204,13 @@ impl Localhost {
|
||||
self.files = match self.scan_dir(self.wrkdir.as_path()) {
|
||||
Ok(files) => files,
|
||||
Err(err) => {
|
||||
error!("Could not scan new directory: {}", err);
|
||||
// Restore directory
|
||||
self.wrkdir = prev_dir;
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
debug!("Changed directory to {}", self.wrkdir.display());
|
||||
Ok(self.wrkdir.clone())
|
||||
}
|
||||
|
||||
@@ -210,6 +227,7 @@ impl Localhost {
|
||||
/// ignex: don't report error if directory already exists
|
||||
pub fn mkdir_ex(&mut self, dir_name: &Path, ignex: bool) -> Result<(), HostError> {
|
||||
let dir_path: PathBuf = self.to_abs_path(dir_name);
|
||||
info!("Making directory {}", dir_path.display());
|
||||
// If dir already exists, return Error
|
||||
if dir_path.exists() {
|
||||
match ignex {
|
||||
@@ -229,13 +247,17 @@ impl Localhost {
|
||||
if dir_name.is_relative() {
|
||||
self.files = self.scan_dir(self.wrkdir.as_path())?;
|
||||
}
|
||||
info!("Created directory {}", dir_path.display());
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::CouldNotCreateFile,
|
||||
Some(err),
|
||||
dir_path.as_path(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Could not make directory: {}", err);
|
||||
Err(HostError::new(
|
||||
HostErrorType::CouldNotCreateFile,
|
||||
Some(err),
|
||||
dir_path.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +268,9 @@ impl Localhost {
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
// If file doesn't exist; return error
|
||||
debug!("Removing directory {}", dir.abs_path.display());
|
||||
if !dir.abs_path.as_path().exists() {
|
||||
error!("Directory doesn't exist");
|
||||
return Err(HostError::new(
|
||||
HostErrorType::NoSuchFileOrDirectory,
|
||||
None,
|
||||
@@ -258,18 +282,24 @@ impl Localhost {
|
||||
Ok(_) => {
|
||||
// Update dir
|
||||
self.files = self.scan_dir(self.wrkdir.as_path())?;
|
||||
info!("Removed directory {}", dir.abs_path.display());
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::DeleteFailed,
|
||||
Some(err),
|
||||
dir.abs_path.as_path(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Could not remove directory: {}", err);
|
||||
Err(HostError::new(
|
||||
HostErrorType::DeleteFailed,
|
||||
Some(err),
|
||||
dir.abs_path.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
// If file doesn't exist; return error
|
||||
debug!("Removing file {}", file.abs_path.display());
|
||||
if !file.abs_path.as_path().exists() {
|
||||
error!("File doesn't exist");
|
||||
return Err(HostError::new(
|
||||
HostErrorType::NoSuchFileOrDirectory,
|
||||
None,
|
||||
@@ -281,13 +311,17 @@ impl Localhost {
|
||||
Ok(_) => {
|
||||
// Update dir
|
||||
self.files = self.scan_dir(self.wrkdir.as_path())?;
|
||||
info!("Removed file {}", file.abs_path.display());
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::DeleteFailed,
|
||||
Some(err),
|
||||
file.abs_path.as_path(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Could not remove file: {}", err);
|
||||
Err(HostError::new(
|
||||
HostErrorType::DeleteFailed,
|
||||
Some(err),
|
||||
file.abs_path.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,13 +336,26 @@ impl Localhost {
|
||||
Ok(_) => {
|
||||
// Scan dir
|
||||
self.files = self.scan_dir(self.wrkdir.as_path())?;
|
||||
debug!(
|
||||
"Moved file {} to {}",
|
||||
entry.get_abs_path().display(),
|
||||
dst_path.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::CouldNotCreateFile,
|
||||
Some(err),
|
||||
abs_path.as_path(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Failed to move {} to {}: {}",
|
||||
entry.get_abs_path().display(),
|
||||
dst_path.display(),
|
||||
err
|
||||
);
|
||||
Err(HostError::new(
|
||||
HostErrorType::CouldNotCreateFile,
|
||||
Some(err),
|
||||
abs_path.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +365,11 @@ impl Localhost {
|
||||
pub fn copy(&mut self, entry: &FsEntry, dst: &Path) -> Result<(), HostError> {
|
||||
// Get absolute path of dest
|
||||
let dst: PathBuf = self.to_abs_path(dst);
|
||||
info!(
|
||||
"Copying file {} to {}",
|
||||
entry.get_abs_path().display(),
|
||||
dst.display()
|
||||
);
|
||||
// Match entry
|
||||
match entry {
|
||||
FsEntry::File(file) => {
|
||||
@@ -333,16 +385,19 @@ impl Localhost {
|
||||
};
|
||||
// Copy entry path to dst path
|
||||
if let Err(err) = std::fs::copy(file.abs_path.as_path(), dst.as_path()) {
|
||||
error!("Failed to copy file: {}", err);
|
||||
return Err(HostError::new(
|
||||
HostErrorType::CouldNotCreateFile,
|
||||
Some(err),
|
||||
file.abs_path.as_path(),
|
||||
));
|
||||
}
|
||||
info!("File copied");
|
||||
}
|
||||
FsEntry::Directory(dir) => {
|
||||
// If destination path doesn't exist, create destination
|
||||
if !dst.exists() {
|
||||
debug!("Directory {} doesn't exist; creating it", dst.display());
|
||||
self.mkdir(dst.as_path())?;
|
||||
}
|
||||
// Scan dir
|
||||
@@ -386,15 +441,17 @@ impl Localhost {
|
||||
/// Stat file and create a FsEntry
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
pub fn stat(&self, path: &Path) -> Result<FsEntry, HostError> {
|
||||
info!("Stating file {}", path.display());
|
||||
let path: PathBuf = self.to_abs_path(path);
|
||||
let attr: Metadata = match fs::metadata(path.as_path()) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
error!("Could not read file metadata: {}", err);
|
||||
return Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
path.as_path(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
let file_name: String = String::from(path.file_name().unwrap().to_str().unwrap_or(""));
|
||||
@@ -454,14 +511,16 @@ impl Localhost {
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
pub fn stat(&self, path: &Path) -> Result<FsEntry, HostError> {
|
||||
let path: PathBuf = self.to_abs_path(path);
|
||||
info!("Stating file {}", path.display());
|
||||
let attr: Metadata = match fs::metadata(path.as_path()) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
error!("Could not read file metadata: {}", err);
|
||||
return Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
path.as_path(),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
let file_name: String = String::from(path.file_name().unwrap().to_str().unwrap_or(""));
|
||||
@@ -523,16 +582,23 @@ impl Localhost {
|
||||
let args: Vec<&str> = cmd.split(' ').collect();
|
||||
let cmd: &str = args.first().unwrap();
|
||||
let argv: &[&str] = &args[1..];
|
||||
info!("Executing command: {} {:?}", cmd, argv);
|
||||
match std::process::Command::new(cmd).args(argv).output() {
|
||||
Ok(output) => match std::str::from_utf8(&output.stdout) {
|
||||
Ok(s) => Ok(s.to_string()),
|
||||
Ok(s) => {
|
||||
info!("Command output: {}", s);
|
||||
Ok(s.to_string())
|
||||
}
|
||||
Err(_) => Ok(String::new()),
|
||||
},
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::ExecutionFailed,
|
||||
Some(err),
|
||||
self.wrkdir.as_path(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Failed to run command: {}", err);
|
||||
Err(HostError::new(
|
||||
HostErrorType::ExecutionFailed,
|
||||
Some(err),
|
||||
self.wrkdir.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,19 +614,32 @@ impl Localhost {
|
||||
let mut mpex = metadata.permissions();
|
||||
mpex.set_mode(self.mode_to_u32(pex));
|
||||
match set_permissions(path.as_path(), mpex) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
path.as_path(),
|
||||
)),
|
||||
Ok(_) => {
|
||||
info!("Changed mode for {} to {:?}", path.display(), pex);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
error!("Could not change mode for file {}: {}", path.display(), err);
|
||||
Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
path.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
path.as_path(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Chmod failed; could not read metadata for file {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
path.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,7 +648,9 @@ impl Localhost {
|
||||
/// Open file for read
|
||||
pub fn open_file_read(&self, file: &Path) -> Result<File, HostError> {
|
||||
let file: PathBuf = self.to_abs_path(file);
|
||||
info!("Opening file {} for read", file.display());
|
||||
if !self.file_exists(file.as_path()) {
|
||||
error!("File doesn't exist!");
|
||||
return Err(HostError::new(
|
||||
HostErrorType::NoSuchFileOrDirectory,
|
||||
None,
|
||||
@@ -583,11 +664,14 @@ impl Localhost {
|
||||
.open(file.as_path())
|
||||
{
|
||||
Ok(f) => Ok(f),
|
||||
Err(err) => Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
file.as_path(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Could not open file for read: {}", err);
|
||||
Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
file.as_path(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,6 +680,7 @@ impl Localhost {
|
||||
/// Open file for write
|
||||
pub fn open_file_write(&self, file: &Path) -> Result<File, HostError> {
|
||||
let file: PathBuf = self.to_abs_path(file);
|
||||
info!("Opening file {} for write", file.display());
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
@@ -603,18 +688,21 @@ impl Localhost {
|
||||
.open(file.as_path())
|
||||
{
|
||||
Ok(f) => Ok(f),
|
||||
Err(err) => match self.file_exists(file.as_path()) {
|
||||
true => Err(HostError::new(
|
||||
HostErrorType::ReadonlyFile,
|
||||
Some(err),
|
||||
file.as_path(),
|
||||
)),
|
||||
false => Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
file.as_path(),
|
||||
)),
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Failed to open file: {}", err);
|
||||
match self.file_exists(file.as_path()) {
|
||||
true => Err(HostError::new(
|
||||
HostErrorType::ReadonlyFile,
|
||||
Some(err),
|
||||
file.as_path(),
|
||||
)),
|
||||
false => Err(HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(err),
|
||||
file.as_path(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,13 +717,15 @@ impl Localhost {
|
||||
///
|
||||
/// Get content of the current directory as a list of fs entry
|
||||
pub fn scan_dir(&self, dir: &Path) -> Result<Vec<FsEntry>, HostError> {
|
||||
info!("Reading directory {}", dir.display());
|
||||
match std::fs::read_dir(dir) {
|
||||
Ok(e) => {
|
||||
let mut fs_entries: Vec<FsEntry> = Vec::new();
|
||||
for entry in e.flatten() {
|
||||
// NOTE: 0.4.1, don't fail if stat for one file fails
|
||||
if let Ok(entry) = self.stat(entry.path().as_path()) {
|
||||
fs_entries.push(entry);
|
||||
match self.stat(entry.path().as_path()) {
|
||||
Ok(entry) => fs_entries.push(entry),
|
||||
Err(e) => error!("Failed to stat {}: {}", entry.path().display(), e),
|
||||
}
|
||||
}
|
||||
Ok(fs_entries)
|
||||
@@ -739,6 +829,8 @@ impl Localhost {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
|
||||
10
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;
|
||||
|
||||
33
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 <https://github.com/veeso/termscp>");
|
||||
println!("Please, consider supporting the author <https://www.buymeacoffee.com/veeso>")
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -78,15 +82,12 @@ fn main() {
|
||||
let mut remote_wrkdir: Option<PathBuf> = None;
|
||||
let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; // Default protocol
|
||||
let mut ticks: Duration = Duration::from_millis(10);
|
||||
let mut log_enabled: bool = true;
|
||||
//Process options
|
||||
let mut opts = Options::new();
|
||||
opts.optopt(
|
||||
"P",
|
||||
"password",
|
||||
"Provide password from CLI (use at your own risk)",
|
||||
"<password>",
|
||||
);
|
||||
opts.optopt("P", "password", "Provide password from CLI", "<password>");
|
||||
opts.optopt("T", "ticks", "Set UI ticks; default 10ms", "<ms>");
|
||||
opts.optflag("q", "quiet", "Disable logging");
|
||||
opts.optflag("v", "version", "");
|
||||
opts.optflag("h", "help", "Print this menu");
|
||||
let matches = match opts.parse(&args[1..]) {
|
||||
@@ -104,11 +105,15 @@ fn main() {
|
||||
// Version
|
||||
if matches.opt_present("v") {
|
||||
eprintln!(
|
||||
"TermSCP - {} - Developed by {}",
|
||||
"termscp - {} - Developed by {}",
|
||||
TERMSCP_VERSION, TERMSCP_AUTHORS,
|
||||
);
|
||||
std::process::exit(255);
|
||||
}
|
||||
// Logging
|
||||
if matches.opt_present("q") {
|
||||
log_enabled = false;
|
||||
}
|
||||
// Match password
|
||||
if let Some(passwd) = matches.opt_str("P") {
|
||||
password = Some(passwd);
|
||||
@@ -159,9 +164,17 @@ fn main() {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => PathBuf::from("/"),
|
||||
};
|
||||
// Setup logging
|
||||
if log_enabled {
|
||||
if let Err(err) = logging::init() {
|
||||
eprintln!("Failed to initialize logging: {}", err);
|
||||
}
|
||||
}
|
||||
info!("termscp {} started!", TERMSCP_VERSION);
|
||||
// Initialize client if necessary
|
||||
let mut start_activity: NextActivity = NextActivity::Authentication;
|
||||
if address.is_some() {
|
||||
debug!("User has specified remote options: address: {:?}, port: {:?}, protocol: {:?}, user: {:?}, password: {}", address, port, protocol, username, utils::fmt::shadow_password(password.as_deref().unwrap_or("")));
|
||||
if password.is_none() {
|
||||
// Ask password if unspecified
|
||||
password = match rpassword::read_password_from_tty(Some("Password: ")) {
|
||||
@@ -177,6 +190,10 @@ fn main() {
|
||||
std::process::exit(255);
|
||||
}
|
||||
};
|
||||
debug!(
|
||||
"Read password from tty: {}",
|
||||
utils::fmt::shadow_password(password.as_deref().unwrap_or(""))
|
||||
);
|
||||
}
|
||||
// In this case the first activity will be FileTransfer
|
||||
start_activity = NextActivity::FileTransfer;
|
||||
@@ -194,7 +211,9 @@ fn main() {
|
||||
manager.set_filetransfer_params(address, port, protocol, username, password, remote_wrkdir);
|
||||
}
|
||||
// Run
|
||||
info!("Starting activity manager...");
|
||||
manager.run(start_activity);
|
||||
info!("termscp terminated");
|
||||
// Then return
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
@@ -68,9 +68,11 @@ impl BookmarksClient {
|
||||
) -> Result<BookmarksClient, SerializerError> {
|
||||
// Create default hosts
|
||||
let default_hosts: UserHosts = Default::default();
|
||||
debug!("Setting up bookmarks client...");
|
||||
// Make a key storage (windows / macos)
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
|
||||
debug!("Setting up KeyStorage");
|
||||
let username: String = whoami::username();
|
||||
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
|
||||
// Check if keyring storage is supported
|
||||
@@ -79,8 +81,14 @@ impl BookmarksClient {
|
||||
#[cfg(test)] // NOTE: when running test, add -test
|
||||
let app_name: &str = "termscp-test";
|
||||
match storage.is_supported() {
|
||||
true => (Box::new(storage), app_name),
|
||||
false => (Box::new(FileStorage::new(storage_path)), "bookmarks"),
|
||||
true => {
|
||||
debug!("Using KeyringStorage");
|
||||
(Box::new(storage), app_name)
|
||||
}
|
||||
false => {
|
||||
warn!("KeyringStorage is not supported; using FileStorage");
|
||||
(Box::new(FileStorage::new(storage_path)), "bookmarks")
|
||||
}
|
||||
}
|
||||
};
|
||||
// Make a key storage (linux / unix)
|
||||
@@ -90,16 +98,22 @@ impl BookmarksClient {
|
||||
let app_name: &str = "bookmarks";
|
||||
#[cfg(test)] // NOTE: when running test, add -test
|
||||
let app_name: &str = "bookmarks-test";
|
||||
debug!("Using FileStorage");
|
||||
(Box::new(FileStorage::new(storage_path)), app_name)
|
||||
};
|
||||
// Load key
|
||||
let key: String = match key_storage.get_key(service_id) {
|
||||
Ok(k) => k,
|
||||
Ok(k) => {
|
||||
debug!("Key loaded with success");
|
||||
k
|
||||
}
|
||||
Err(e) => match e {
|
||||
KeyStorageError::NoSuchKey => {
|
||||
// If no such key, generate key and set it into the storage
|
||||
let key: String = Self::generate_key();
|
||||
debug!("Key doesn't exist yet or could not be loaded; generated a new key");
|
||||
if let Err(e) = key_storage.set_key(service_id, key.as_str()) {
|
||||
error!("Failed to set new key into storage: {}", e);
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
format!("Could not write key to storage: {}", e),
|
||||
@@ -109,10 +123,11 @@ impl BookmarksClient {
|
||||
key
|
||||
}
|
||||
_ => {
|
||||
error!("Failed to get key from storage: {}", e);
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
format!("Could not get key from storage: {}", e),
|
||||
))
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -124,15 +139,19 @@ impl BookmarksClient {
|
||||
};
|
||||
// If bookmark file doesn't exist, initialize it
|
||||
if !bookmarks_file.exists() {
|
||||
info!("Bookmarks file doesn't exist yet; creating it...");
|
||||
if let Err(err) = client.write_bookmarks() {
|
||||
error!("Failed to create bookmarks file: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
// Load bookmarks from file
|
||||
if let Err(err) = client.read_bookmarks() {
|
||||
error!("Failed to load bookmarks: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
info!("Bookmarks client initialized");
|
||||
// Load key
|
||||
Ok(client)
|
||||
}
|
||||
@@ -152,19 +171,29 @@ impl BookmarksClient {
|
||||
key: &str,
|
||||
) -> Option<(String, u16, FileTransferProtocol, String, Option<String>)> {
|
||||
let entry: &Bookmark = self.hosts.bookmarks.get(key)?;
|
||||
debug!("Getting bookmark {}", key);
|
||||
Some((
|
||||
entry.address.clone(),
|
||||
entry.port,
|
||||
match FileTransferProtocol::from_str(entry.protocol.as_str()) {
|
||||
Ok(proto) => proto,
|
||||
Err(_) => FileTransferProtocol::Sftp, // Default
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Found invalid protocol in bookmarks: {}; defaulting to SFTP",
|
||||
err
|
||||
);
|
||||
FileTransferProtocol::Sftp // Default
|
||||
}
|
||||
},
|
||||
entry.username.clone(),
|
||||
match &entry.password {
|
||||
// Decrypted password if Some; if decryption fails return None
|
||||
Some(pwd) => match self.decrypt_str(pwd.as_str()) {
|
||||
Ok(decrypted_pwd) => Some(decrypted_pwd),
|
||||
Err(_) => None,
|
||||
Err(err) => {
|
||||
error!("Failed to decrypt password for bookmark: {}", err);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
@@ -184,9 +213,11 @@ impl BookmarksClient {
|
||||
password: Option<String>,
|
||||
) {
|
||||
if name.is_empty() {
|
||||
error!("Fatal error; bookmark name is empty");
|
||||
panic!("Bookmark name can't be empty");
|
||||
}
|
||||
// Make bookmark
|
||||
info!("Added bookmark {} with address {}", name, addr);
|
||||
let host: Bookmark = self.make_bookmark(addr, port, protocol, username, password);
|
||||
self.hosts.bookmarks.insert(name, host);
|
||||
}
|
||||
@@ -196,6 +227,7 @@ impl BookmarksClient {
|
||||
/// Delete entry from bookmarks
|
||||
pub fn del_bookmark(&mut self, name: &str) {
|
||||
let _ = self.hosts.bookmarks.remove(name);
|
||||
info!("Removed bookmark {}", name);
|
||||
}
|
||||
/// ### iter_recents
|
||||
///
|
||||
@@ -209,13 +241,20 @@ impl BookmarksClient {
|
||||
/// Get recent associated to key
|
||||
pub fn get_recent(&self, key: &str) -> Option<(String, u16, FileTransferProtocol, String)> {
|
||||
// NOTE: password is not decrypted; recents will never have password
|
||||
info!("Getting bookmark {}", key);
|
||||
let entry: &Bookmark = self.hosts.recents.get(key)?;
|
||||
Some((
|
||||
entry.address.clone(),
|
||||
entry.port,
|
||||
match FileTransferProtocol::from_str(entry.protocol.as_str()) {
|
||||
Ok(proto) => proto,
|
||||
Err(_) => FileTransferProtocol::Sftp, // Default
|
||||
Err(err) => {
|
||||
error!(
|
||||
"Found invalid protocol in bookmarks: {}; defaulting to SFTP",
|
||||
err
|
||||
);
|
||||
FileTransferProtocol::Sftp // Default
|
||||
}
|
||||
},
|
||||
entry.username.clone(),
|
||||
))
|
||||
@@ -236,6 +275,7 @@ impl BookmarksClient {
|
||||
// Check if duplicated
|
||||
for recent_host in self.hosts.recents.values() {
|
||||
if *recent_host == host {
|
||||
debug!("Discarding recent since duplicated ({})", host.address);
|
||||
// Don't save duplicates
|
||||
return;
|
||||
}
|
||||
@@ -252,6 +292,7 @@ impl BookmarksClient {
|
||||
// Delete keys starting from the last one
|
||||
for key in keys.iter() {
|
||||
let _ = self.hosts.recents.remove(key);
|
||||
debug!("Removed recent bookmark {}", key);
|
||||
// If length is < self.recents_size; break
|
||||
if self.hosts.recents.len() < self.recents_size {
|
||||
break;
|
||||
@@ -259,6 +300,7 @@ impl BookmarksClient {
|
||||
}
|
||||
}
|
||||
let name: String = fmt_time(SystemTime::now(), "ISO%Y%m%dT%H%M%S");
|
||||
info!("Saved recent host {} ({})", name, host.address);
|
||||
self.hosts.recents.insert(name, host);
|
||||
}
|
||||
|
||||
@@ -267,6 +309,7 @@ impl BookmarksClient {
|
||||
/// Delete entry from recents
|
||||
pub fn del_recent(&mut self, name: &str) {
|
||||
let _ = self.hosts.recents.remove(name);
|
||||
info!("Removed recent host {}", name);
|
||||
}
|
||||
|
||||
/// ### write_bookmarks
|
||||
@@ -274,6 +317,7 @@ impl BookmarksClient {
|
||||
/// Write bookmarks to file
|
||||
pub fn write_bookmarks(&self) -> Result<(), SerializerError> {
|
||||
// Open file
|
||||
debug!("Writing bookmarks");
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
@@ -284,10 +328,13 @@ impl BookmarksClient {
|
||||
let serializer: BookmarkSerializer = BookmarkSerializer {};
|
||||
serializer.serialize(Box::new(writer), &self.hosts)
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Failed to write bookmarks: {}", err);
|
||||
Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +343,7 @@ impl BookmarksClient {
|
||||
/// Read bookmarks from file
|
||||
fn read_bookmarks(&mut self) -> Result<(), SerializerError> {
|
||||
// Open bookmarks file for read
|
||||
debug!("Reading bookmarks");
|
||||
match OpenOptions::new()
|
||||
.read(true)
|
||||
.open(self.bookmarks_file.as_path())
|
||||
@@ -311,10 +359,13 @@ impl BookmarksClient {
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Failed to read bookmarks: {}", err);
|
||||
Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +423,8 @@ impl BookmarksClient {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ impl ConfigClient {
|
||||
pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result<ConfigClient, SerializerError> {
|
||||
// Initialize a default configuration
|
||||
let default_config: UserConfig = UserConfig::default();
|
||||
info!(
|
||||
"Setting up config client with config path {} and SSH key directory {}",
|
||||
config_path.display(),
|
||||
ssh_key_dir.display()
|
||||
);
|
||||
// Create client
|
||||
let mut client: ConfigClient = ConfigClient {
|
||||
config: default_config,
|
||||
@@ -67,6 +72,7 @@ impl ConfigClient {
|
||||
// If ssh key directory doesn't exist, create it
|
||||
if !ssh_key_dir.exists() {
|
||||
if let Err(err) = create_dir(ssh_key_dir) {
|
||||
error!("Failed to create SSH key dir: {}", err);
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
format!(
|
||||
@@ -76,17 +82,22 @@ impl ConfigClient {
|
||||
),
|
||||
));
|
||||
}
|
||||
debug!("Created SSH key directory");
|
||||
}
|
||||
// If Config file doesn't exist, create it
|
||||
if !config_path.exists() {
|
||||
if let Err(err) = client.write_config() {
|
||||
error!("Couldn't create configuration file: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
debug!("Config file didn't exist; created file");
|
||||
} else {
|
||||
// otherwise Load configuration from file
|
||||
if let Err(err) = client.read_config() {
|
||||
error!("Couldn't read configuration file: {}", err);
|
||||
return Err(err);
|
||||
}
|
||||
debug!("Read configuration file");
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
@@ -176,23 +187,40 @@ impl ConfigClient {
|
||||
self.config.user_interface.group_dirs = val.map(|val| val.to_string());
|
||||
}
|
||||
|
||||
/// ### get_file_fmt
|
||||
/// ### get_local_file_fmt
|
||||
///
|
||||
/// Get current file fmt
|
||||
pub fn get_file_fmt(&self) -> Option<String> {
|
||||
/// Get current file fmt for local host
|
||||
pub fn get_local_file_fmt(&self) -> Option<String> {
|
||||
self.config.user_interface.file_fmt.clone()
|
||||
}
|
||||
|
||||
/// ### set_file_fmt
|
||||
/// ### set_local_file_fmt
|
||||
///
|
||||
/// Set file fmt parameter
|
||||
pub fn set_file_fmt(&mut self, s: String) {
|
||||
/// Set file fmt parameter for local host
|
||||
pub fn set_local_file_fmt(&mut self, s: String) {
|
||||
self.config.user_interface.file_fmt = match s.is_empty() {
|
||||
true => None,
|
||||
false => Some(s),
|
||||
};
|
||||
}
|
||||
|
||||
/// ### get_remote_file_fmt
|
||||
///
|
||||
/// Get current file fmt for remote host
|
||||
pub fn get_remote_file_fmt(&self) -> Option<String> {
|
||||
self.config.user_interface.remote_file_fmt.clone()
|
||||
}
|
||||
|
||||
/// ### set_remote_file_fmt
|
||||
///
|
||||
/// Set file fmt parameter for remote host
|
||||
pub fn set_remote_file_fmt(&mut self, s: String) {
|
||||
self.config.user_interface.remote_file_fmt = match s.is_empty() {
|
||||
true => None,
|
||||
false => Some(s),
|
||||
};
|
||||
}
|
||||
|
||||
// SSH Keys
|
||||
|
||||
/// ### save_ssh_key
|
||||
@@ -213,12 +241,18 @@ impl ConfigClient {
|
||||
p.push(format!("{}.key", host_name));
|
||||
p
|
||||
};
|
||||
info!(
|
||||
"Writing SSH file to {} for host {}",
|
||||
ssh_key_path.display(),
|
||||
host_name
|
||||
);
|
||||
// Write key to file
|
||||
let mut f: File = match File::create(ssh_key_path.as_path()) {
|
||||
Ok(f) => f,
|
||||
Err(err) => return Self::make_io_err(err),
|
||||
};
|
||||
if let Err(err) = f.write_all(ssh_key.as_bytes()) {
|
||||
error!("Failed to write SSH key to file: {}", err);
|
||||
return Self::make_io_err(err);
|
||||
}
|
||||
// Add host to keys
|
||||
@@ -234,6 +268,7 @@ impl ConfigClient {
|
||||
/// and also commits changes to configuration, to prevent incoerent data
|
||||
pub fn del_ssh_key(&mut self, host: &str, username: &str) -> Result<(), SerializerError> {
|
||||
// Remove key from configuration and get key path
|
||||
info!("Removing key for {}@{}", host, username);
|
||||
let key_path: PathBuf = match self
|
||||
.config
|
||||
.remote
|
||||
@@ -245,6 +280,7 @@ impl ConfigClient {
|
||||
};
|
||||
// Remove file
|
||||
if let Err(err) = remove_file(key_path.as_path()) {
|
||||
error!("Failed to remove key file {}: {}", key_path.display(), err);
|
||||
return Self::make_io_err(err);
|
||||
}
|
||||
// Commit changes to configuration
|
||||
@@ -293,10 +329,13 @@ impl ConfigClient {
|
||||
let serializer: ConfigSerializer = ConfigSerializer {};
|
||||
serializer.serialize(Box::new(writer), &self.config)
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Failed to write configuration file: {}", err);
|
||||
Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,10 +359,13 @@ impl ConfigClient {
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
Err(err) => {
|
||||
error!("Failed to read configuration: {}", err);
|
||||
Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +406,7 @@ mod tests {
|
||||
use crate::config::UserConfig;
|
||||
use crate::utils::random::random_alphanumeric_with_len;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Read;
|
||||
|
||||
#[test]
|
||||
@@ -496,18 +539,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_file_fmt() {
|
||||
fn test_system_config_local_file_fmt() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(client.get_file_fmt(), None);
|
||||
client.set_file_fmt(String::from("{NAME}"));
|
||||
assert_eq!(client.get_file_fmt().unwrap(), String::from("{NAME}"));
|
||||
assert_eq!(client.get_local_file_fmt(), None);
|
||||
client.set_local_file_fmt(String::from("{NAME}"));
|
||||
assert_eq!(client.get_local_file_fmt().unwrap(), String::from("{NAME}"));
|
||||
// Delete
|
||||
client.set_file_fmt(String::from(""));
|
||||
assert_eq!(client.get_file_fmt(), None);
|
||||
client.set_local_file_fmt(String::from(""));
|
||||
assert_eq!(client.get_local_file_fmt(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_remote_file_fmt() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(client.get_remote_file_fmt(), None);
|
||||
client.set_remote_file_fmt(String::from("{NAME}"));
|
||||
assert_eq!(
|
||||
client.get_remote_file_fmt().unwrap(),
|
||||
String::from("{NAME}")
|
||||
);
|
||||
// Delete
|
||||
client.set_remote_file_fmt(String::from(""));
|
||||
assert_eq!(client.get_remote_file_fmt(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/"));
|
||||
|
||||
@@ -104,6 +104,7 @@ mod tests {
|
||||
extern crate whoami;
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use whoami::username;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -78,6 +78,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_system_keys_mod_errors() {
|
||||
assert_eq!(
|
||||
|
||||
72
src/system/logging.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -42,6 +42,7 @@ impl SshKeyStorage {
|
||||
pub fn storage_from_config(cfg_client: &ConfigClient) -> Self {
|
||||
let mut hosts: HashMap<String, PathBuf> =
|
||||
HashMap::with_capacity(cfg_client.iter_ssh_keys().count());
|
||||
debug!("Setting up SSH key storage");
|
||||
// Iterate over keys
|
||||
for key in cfg_client.iter_ssh_keys() {
|
||||
match cfg_client.get_ssh_key(key) {
|
||||
@@ -52,8 +53,12 @@ impl SshKeyStorage {
|
||||
}
|
||||
None => continue,
|
||||
},
|
||||
Err(_) => continue,
|
||||
Err(err) => {
|
||||
error!("Failed to get SSH key for {}: {}", key, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
info!("Got SSH key for {}", key);
|
||||
}
|
||||
// Return storage
|
||||
SshKeyStorage { hosts }
|
||||
@@ -88,8 +93,9 @@ impl SshKeyStorage {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::system::config_client::ConfigClient;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -32,11 +32,11 @@ extern crate dirs;
|
||||
use super::{AuthActivity, FileTransferProtocol};
|
||||
use crate::system::bookmarks_client::BookmarksClient;
|
||||
use crate::system::environment;
|
||||
use crate::ui::layout::props::PropValue;
|
||||
use crate::ui::layout::Payload;
|
||||
|
||||
// Ext
|
||||
use std::path::PathBuf;
|
||||
use tuirealm::components::{input::InputPropsBuilder, radio::RadioPropsBuilder};
|
||||
use tuirealm::{Payload, PropsBuilder, Value};
|
||||
|
||||
impl AuthActivity {
|
||||
/// ### del_bookmark
|
||||
@@ -83,10 +83,10 @@ impl AuthActivity {
|
||||
let password: Option<String> = match save_password {
|
||||
true => match self
|
||||
.view
|
||||
.get_value(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD)
|
||||
.get_state(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD)
|
||||
{
|
||||
Some(Payload::Unsigned(0)) => Some(password), // Yes
|
||||
_ => None, // No such component / No
|
||||
Some(Payload::One(Value::Usize(0))) => Some(password), // Yes
|
||||
_ => None, // No such component / No
|
||||
},
|
||||
false => None,
|
||||
};
|
||||
@@ -246,32 +246,29 @@ impl AuthActivity {
|
||||
password: Option<String>,
|
||||
) {
|
||||
// Load parameters into components
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_ADDR) {
|
||||
let props = props.with_value(PropValue::Str(addr)).build();
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_ADDR) {
|
||||
let props = InputPropsBuilder::from(props).with_value(addr).build();
|
||||
self.view.update(super::COMPONENT_INPUT_ADDR, props);
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_PORT) {
|
||||
let props = props.with_value(PropValue::Str(port.to_string())).build();
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_PORT) {
|
||||
let props = InputPropsBuilder::from(props)
|
||||
.with_value(port.to_string())
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_INPUT_PORT, props);
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_PROTOCOL) {
|
||||
let props = props
|
||||
.with_value(PropValue::Unsigned(match protocol {
|
||||
FileTransferProtocol::Sftp => 0,
|
||||
FileTransferProtocol::Scp => 1,
|
||||
FileTransferProtocol::Ftp(false) => 2,
|
||||
FileTransferProtocol::Ftp(true) => 3,
|
||||
}))
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_PROTOCOL) {
|
||||
let props = RadioPropsBuilder::from(props)
|
||||
.with_value(Self::protocol_enum_to_opt(protocol))
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_RADIO_PROTOCOL, props);
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_USERNAME) {
|
||||
let props = props.with_value(PropValue::Str(username)).build();
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_USERNAME) {
|
||||
let props = InputPropsBuilder::from(props).with_value(username).build();
|
||||
self.view.update(super::COMPONENT_INPUT_USERNAME, props);
|
||||
}
|
||||
if let Some(password) = password {
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_PASSWORD) {
|
||||
let props = props.with_value(PropValue::Str(password)).build();
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_PASSWORD) {
|
||||
let props = InputPropsBuilder::from(props).with_value(password).build();
|
||||
self.view.update(super::COMPONENT_INPUT_PASSWORD, props);
|
||||
}
|
||||
}
|
||||
71
src/ui/activities/auth/misc.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! ## AuthActivity
|
||||
//!
|
||||
//! `auth_activity` is the module which implements the authentication activity
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
use super::{AuthActivity, FileTransferProtocol};
|
||||
|
||||
impl AuthActivity {
|
||||
/// ### protocol_opt_to_enum
|
||||
///
|
||||
/// Convert radio index for protocol into a `FileTransferProtocol`
|
||||
pub(super) fn protocol_opt_to_enum(protocol: usize) -> FileTransferProtocol {
|
||||
match protocol {
|
||||
1 => FileTransferProtocol::Scp,
|
||||
2 => FileTransferProtocol::Ftp(false),
|
||||
3 => FileTransferProtocol::Ftp(true),
|
||||
_ => FileTransferProtocol::Sftp,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### protocol_enum_to_opt
|
||||
///
|
||||
/// Convert `FileTransferProtocol` enum into radio group index
|
||||
pub(super) fn protocol_enum_to_opt(protocol: FileTransferProtocol) -> usize {
|
||||
match protocol {
|
||||
FileTransferProtocol::Sftp => 0,
|
||||
FileTransferProtocol::Scp => 1,
|
||||
FileTransferProtocol::Ftp(false) => 2,
|
||||
FileTransferProtocol::Ftp(true) => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_default_port_for_protocol
|
||||
///
|
||||
/// Get the default port for protocol
|
||||
pub(super) fn get_default_port_for_protocol(protocol: FileTransferProtocol) -> u16 {
|
||||
match protocol {
|
||||
FileTransferProtocol::Sftp | FileTransferProtocol::Scp => 22,
|
||||
FileTransferProtocol::Ftp(_) => 21,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### is_port_standard
|
||||
///
|
||||
/// Returns whether the port is standard or not
|
||||
pub(super) fn is_port_standard(port: u16) -> bool {
|
||||
port < 1024
|
||||
}
|
||||
}
|
||||
@@ -27,26 +27,28 @@
|
||||
*/
|
||||
// Sub modules
|
||||
mod bookmarks;
|
||||
mod misc;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
// Dependencies
|
||||
extern crate crossterm;
|
||||
extern crate tui;
|
||||
extern crate tuirealm;
|
||||
|
||||
// locals
|
||||
use super::{Activity, Context, ExitReason};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::system::bookmarks_client::BookmarksClient;
|
||||
use crate::ui::context::FileTransferParams;
|
||||
use crate::ui::layout::view::View;
|
||||
use crate::utils::git;
|
||||
|
||||
// Includes
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use tuirealm::View;
|
||||
|
||||
// -- components
|
||||
const COMPONENT_TEXT_HEADER: &str = "TEXT_HEADER";
|
||||
const COMPONENT_TEXT_H1: &str = "TEXT_H1";
|
||||
const COMPONENT_TEXT_H2: &str = "TEXT_H2";
|
||||
const COMPONENT_TEXT_NEW_VERSION: &str = "TEXT_NEW_VERSION";
|
||||
const COMPONENT_TEXT_FOOTER: &str = "TEXT_FOOTER";
|
||||
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
|
||||
@@ -106,17 +108,24 @@ impl AuthActivity {
|
||||
///
|
||||
/// If enabled in configuration, check for updates from Github
|
||||
fn check_for_updates(&mut self) {
|
||||
debug!("Check for updates...");
|
||||
// Check version only if unset in the store
|
||||
let ctx: &Context = self.context.as_ref().unwrap();
|
||||
if !ctx.store.isset(STORE_KEY_LATEST_VERSION) {
|
||||
debug!("Version is not set in storage");
|
||||
let mut new_version: Option<String> = match ctx.config_client.as_ref() {
|
||||
Some(client) => {
|
||||
if client.get_check_for_updates() {
|
||||
debug!("Check for updates is enabled");
|
||||
// Send request
|
||||
match git::check_for_updates(env!("CARGO_PKG_VERSION")) {
|
||||
Ok(version) => version,
|
||||
Ok(version) => {
|
||||
info!("Latest version is: {:?}", version);
|
||||
version
|
||||
}
|
||||
Err(err) => {
|
||||
// Report error
|
||||
error!("Failed to get latest version: {}", err);
|
||||
self.mount_error(
|
||||
format!("Could not check for new updates: {}", err).as_str(),
|
||||
);
|
||||
@@ -125,6 +134,7 @@ impl AuthActivity {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Check for updates is disabled");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -147,6 +157,7 @@ impl Activity for AuthActivity {
|
||||
/// `on_create` must initialize all the data structures used by the activity
|
||||
/// Context is taken from activity manager and will be released only when activity is destroyed
|
||||
fn on_create(&mut self, mut context: Context) {
|
||||
debug!("Initializing activity");
|
||||
// Initialize file transfer params
|
||||
context.ft_params = Some(FileTransferParams::default());
|
||||
// Set context
|
||||
@@ -154,19 +165,25 @@ impl Activity for AuthActivity {
|
||||
// Clear terminal
|
||||
self.context.as_mut().unwrap().clear_screen();
|
||||
// Put raw mode on enabled
|
||||
let _ = enable_raw_mode();
|
||||
// Init bookmarks client
|
||||
if self.bookmarks_client.is_none() {
|
||||
self.init_bookmarks_client();
|
||||
}
|
||||
// Verify error state from context
|
||||
if let Some(err) = self.context.as_mut().unwrap().get_error() {
|
||||
self.mount_error(err.as_str());
|
||||
if let Err(err) = enable_raw_mode() {
|
||||
error!("Failed to enter raw mode: {}", err);
|
||||
}
|
||||
// If check for updates is enabled, check for updates
|
||||
self.check_for_updates();
|
||||
// Initialize view
|
||||
self.init();
|
||||
// Init bookmarks client
|
||||
if self.bookmarks_client.is_none() {
|
||||
self.init_bookmarks_client();
|
||||
// View bookarmsk
|
||||
self.view_bookmarks();
|
||||
self.view_recent_connections();
|
||||
}
|
||||
// Verify error state from context
|
||||
if let Some(err) = self.context.as_mut().unwrap().get_error() {
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
info!("Activity initialized");
|
||||
}
|
||||
|
||||
/// ### on_draw
|
||||
@@ -211,7 +228,9 @@ impl Activity for AuthActivity {
|
||||
/// This function finally releases the context
|
||||
fn on_destroy(&mut self) -> Option<Context> {
|
||||
// Disable raw mode
|
||||
let _ = disable_raw_mode();
|
||||
if let Err(err) = disable_raw_mode() {
|
||||
error!("Failed to disable raw mode: {}", err);
|
||||
}
|
||||
self.context.as_ref()?;
|
||||
// Clear terminal and return
|
||||
match self.context.take() {
|
||||
@@ -27,15 +27,16 @@
|
||||
*/
|
||||
// locals
|
||||
use super::{
|
||||
AuthActivity, FileTransferParams, COMPONENT_BOOKMARKS_LIST, COMPONENT_INPUT_ADDR,
|
||||
COMPONENT_INPUT_BOOKMARK_NAME, COMPONENT_INPUT_PASSWORD, COMPONENT_INPUT_PORT,
|
||||
COMPONENT_INPUT_USERNAME, COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
|
||||
AuthActivity, FileTransferParams, FileTransferProtocol, COMPONENT_BOOKMARKS_LIST,
|
||||
COMPONENT_INPUT_ADDR, COMPONENT_INPUT_BOOKMARK_NAME, COMPONENT_INPUT_PASSWORD,
|
||||
COMPONENT_INPUT_PORT, COMPONENT_INPUT_USERNAME, COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
|
||||
COMPONENT_RADIO_BOOKMARK_DEL_RECENT, COMPONENT_RADIO_BOOKMARK_SAVE_PWD,
|
||||
COMPONENT_RADIO_PROTOCOL, COMPONENT_RADIO_QUIT, COMPONENT_RECENTS_LIST, COMPONENT_TEXT_ERROR,
|
||||
COMPONENT_TEXT_HELP,
|
||||
};
|
||||
use crate::ui::activities::keymap::*;
|
||||
use crate::ui::layout::{Msg, Payload};
|
||||
use crate::ui::keymap::*;
|
||||
use tuirealm::components::InputPropsBuilder;
|
||||
use tuirealm::{Msg, Payload, PropsBuilder, Value};
|
||||
|
||||
// -- update
|
||||
|
||||
@@ -51,17 +52,17 @@ impl AuthActivity {
|
||||
None => None, // Exit after None
|
||||
Some(msg) => match msg {
|
||||
// Focus ( DOWN )
|
||||
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_DOWN) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_ADDR);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_ADDR, &MSG_KEY_DOWN) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_PORT);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_PORT, &MSG_KEY_DOWN) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_RADIO_PROTOCOL);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_DOWN) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_USERNAME);
|
||||
None
|
||||
@@ -73,7 +74,7 @@ impl AuthActivity {
|
||||
}
|
||||
(COMPONENT_INPUT_PASSWORD, &MSG_KEY_DOWN) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_ADDR);
|
||||
self.view.active(COMPONENT_RADIO_PROTOCOL);
|
||||
None
|
||||
}
|
||||
// Focus ( UP )
|
||||
@@ -83,11 +84,6 @@ impl AuthActivity {
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_USERNAME, &MSG_KEY_UP) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_RADIO_PROTOCOL);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_UP) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_PORT);
|
||||
None
|
||||
@@ -98,10 +94,28 @@ impl AuthActivity {
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_ADDR, &MSG_KEY_UP) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_RADIO_PROTOCOL);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_PROTOCOL, &MSG_KEY_UP) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_PASSWORD);
|
||||
None
|
||||
}
|
||||
// Protocol - On Change
|
||||
(COMPONENT_RADIO_PROTOCOL, Msg::OnChange(Payload::One(Value::Usize(protocol)))) => {
|
||||
// If port is standard, update the current port with default for selected protocol
|
||||
let protocol: FileTransferProtocol = Self::protocol_opt_to_enum(*protocol);
|
||||
// Get port
|
||||
let port: u16 = self.get_input_port();
|
||||
match Self::is_port_standard(port) {
|
||||
false => None, // Return None
|
||||
true => {
|
||||
self.update_input_port(Self::get_default_port_for_protocol(protocol))
|
||||
}
|
||||
}
|
||||
}
|
||||
// <TAB> bookmarks
|
||||
(COMPONENT_BOOKMARKS_LIST, &MSG_KEY_TAB)
|
||||
| (COMPONENT_RECENTS_LIST, &MSG_KEY_TAB) => {
|
||||
@@ -140,13 +154,13 @@ impl AuthActivity {
|
||||
None
|
||||
}
|
||||
// Enter
|
||||
(COMPONENT_BOOKMARKS_LIST, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
(COMPONENT_BOOKMARKS_LIST, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
|
||||
self.load_bookmark(*idx);
|
||||
// Give focus to input password
|
||||
self.view.active(COMPONENT_INPUT_PASSWORD);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RECENTS_LIST, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
(COMPONENT_RECENTS_LIST, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
|
||||
self.load_recent(*idx);
|
||||
// Give focus to input password
|
||||
self.view.active(COMPONENT_INPUT_PASSWORD);
|
||||
@@ -156,7 +170,7 @@ impl AuthActivity {
|
||||
// Del bookmarks
|
||||
(
|
||||
COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
|
||||
Msg::OnSubmit(Payload::Unsigned(index)),
|
||||
Msg::OnSubmit(Payload::One(Value::Usize(index))),
|
||||
) => {
|
||||
// hide bookmark delete
|
||||
self.umount_bookmark_del_dialog();
|
||||
@@ -164,8 +178,8 @@ impl AuthActivity {
|
||||
match *index {
|
||||
0 => {
|
||||
// Get selected bookmark
|
||||
match self.view.get_value(COMPONENT_BOOKMARKS_LIST) {
|
||||
Some(Payload::Unsigned(index)) => {
|
||||
match self.view.get_state(COMPONENT_BOOKMARKS_LIST) {
|
||||
Some(Payload::One(Value::Usize(index))) => {
|
||||
// Delete bookmark
|
||||
self.del_bookmark(index);
|
||||
// Update bookmarks
|
||||
@@ -177,15 +191,18 @@ impl AuthActivity {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(COMPONENT_RADIO_BOOKMARK_DEL_RECENT, Msg::OnSubmit(Payload::Unsigned(index))) => {
|
||||
(
|
||||
COMPONENT_RADIO_BOOKMARK_DEL_RECENT,
|
||||
Msg::OnSubmit(Payload::One(Value::Usize(index))),
|
||||
) => {
|
||||
// hide bookmark delete
|
||||
self.umount_recent_del_dialog();
|
||||
// Index must be 0 => YES
|
||||
match *index {
|
||||
0 => {
|
||||
// Get selected bookmark
|
||||
match self.view.get_value(COMPONENT_RECENTS_LIST) {
|
||||
Some(Payload::Unsigned(index)) => {
|
||||
match self.view.get_state(COMPONENT_RECENTS_LIST) {
|
||||
Some(Payload::One(Value::Usize(index))) => {
|
||||
// Delete recent
|
||||
self.del_recent(index);
|
||||
// Update bookmarks
|
||||
@@ -199,37 +216,20 @@ impl AuthActivity {
|
||||
}
|
||||
// <ESC> hide tab
|
||||
(COMPONENT_RADIO_BOOKMARK_DEL_RECENT, &MSG_KEY_ESC) => {
|
||||
match self
|
||||
.view
|
||||
.get_props(COMPONENT_RADIO_BOOKMARK_DEL_RECENT)
|
||||
.as_mut()
|
||||
{
|
||||
Some(props) => {
|
||||
let msg = self.view.update(
|
||||
COMPONENT_RADIO_BOOKMARK_DEL_RECENT,
|
||||
props.hidden().build(),
|
||||
);
|
||||
self.update(msg)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
self.umount_recent_del_dialog();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, &MSG_KEY_ESC) => {
|
||||
match self
|
||||
.view
|
||||
.get_props(COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK)
|
||||
.as_mut()
|
||||
{
|
||||
Some(props) => {
|
||||
let msg = self.view.update(
|
||||
COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
|
||||
props.hidden().build(),
|
||||
);
|
||||
self.update(msg)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
self.umount_bookmark_del_dialog();
|
||||
None
|
||||
}
|
||||
// Error message
|
||||
(COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) => {
|
||||
// Umount text error
|
||||
self.umount_error();
|
||||
None
|
||||
}
|
||||
(COMPONENT_TEXT_ERROR, _) => None,
|
||||
// Help
|
||||
(_, &MSG_KEY_CTRL_H) => {
|
||||
// Show help
|
||||
@@ -269,16 +269,18 @@ impl AuthActivity {
|
||||
| (COMPONENT_RADIO_BOOKMARK_SAVE_PWD, Msg::OnSubmit(_)) => {
|
||||
// Get values
|
||||
let bookmark_name: String =
|
||||
match self.view.get_value(COMPONENT_INPUT_BOOKMARK_NAME) {
|
||||
Some(Payload::Text(s)) => s,
|
||||
match self.view.get_state(COMPONENT_INPUT_BOOKMARK_NAME) {
|
||||
Some(Payload::One(Value::Str(s))) => s,
|
||||
_ => String::new(),
|
||||
};
|
||||
let save_pwd: bool = matches!(
|
||||
self.view.get_value(COMPONENT_RADIO_BOOKMARK_SAVE_PWD),
|
||||
Some(Payload::Unsigned(0))
|
||||
self.view.get_state(COMPONENT_RADIO_BOOKMARK_SAVE_PWD),
|
||||
Some(Payload::One(Value::Usize(0)))
|
||||
);
|
||||
// Save bookmark
|
||||
self.save_bookmark(bookmark_name, save_pwd);
|
||||
if !bookmark_name.is_empty() {
|
||||
self.save_bookmark(bookmark_name, save_pwd);
|
||||
}
|
||||
// Umount popup
|
||||
self.umount_bookmark_save_dialog();
|
||||
// Reload bookmarks
|
||||
@@ -291,14 +293,8 @@ impl AuthActivity {
|
||||
self.umount_bookmark_save_dialog();
|
||||
None
|
||||
}
|
||||
// Error message
|
||||
(COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) => {
|
||||
// Umount text error
|
||||
self.umount_error();
|
||||
None
|
||||
}
|
||||
// Quit dialog
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(choice))) => {
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(choice)))) => {
|
||||
// If choice is 0, quit termscp
|
||||
if *choice == 0 {
|
||||
self.exit_reason = Some(super::ExitReason::Quit);
|
||||
@@ -343,4 +339,16 @@ impl AuthActivity {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn update_input_port(&mut self, port: u16) -> Option<(String, Msg)> {
|
||||
match self.view.get_props(COMPONENT_INPUT_PORT) {
|
||||
None => None,
|
||||
Some(props) => {
|
||||
let props = InputPropsBuilder::from(props)
|
||||
.with_value(port.to_string())
|
||||
.build();
|
||||
self.view.update(COMPONENT_INPUT_PORT, props)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,20 +27,27 @@
|
||||
*/
|
||||
// Locals
|
||||
use super::{AuthActivity, Context, FileTransferProtocol};
|
||||
use crate::ui::layout::components::{
|
||||
bookmark_list::BookmarkList, input::Input, msgbox::MsgBox, radio_group::RadioGroup,
|
||||
table::Table, text::Text, title::Title,
|
||||
use crate::ui::components::{
|
||||
bookmark_list::{BookmarkList, BookmarkListPropsBuilder},
|
||||
msgbox::{MsgBox, MsgBoxPropsBuilder},
|
||||
};
|
||||
use crate::ui::layout::props::{
|
||||
InputType, PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
|
||||
};
|
||||
use crate::ui::layout::utils::draw_area_in;
|
||||
use crate::ui::layout::{Msg, Payload};
|
||||
use crate::utils::ui::draw_area_in;
|
||||
// Ext
|
||||
use tui::{
|
||||
use tuirealm::components::{
|
||||
input::{Input, InputPropsBuilder},
|
||||
label::{Label, LabelPropsBuilder},
|
||||
radio::{Radio, RadioPropsBuilder},
|
||||
span::{Span, SpanPropsBuilder},
|
||||
table::{Table, TablePropsBuilder},
|
||||
};
|
||||
use tuirealm::tui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
widgets::Clear,
|
||||
widgets::{BorderType, Borders, Clear},
|
||||
};
|
||||
use tuirealm::{
|
||||
props::{InputType, PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder},
|
||||
Msg, Payload, Value,
|
||||
};
|
||||
|
||||
impl AuthActivity {
|
||||
@@ -48,37 +55,74 @@ impl AuthActivity {
|
||||
///
|
||||
/// Initialize view, mounting all startup components inside the view
|
||||
pub(super) fn init(&mut self) {
|
||||
// Header
|
||||
self.view.mount(super::COMPONENT_TEXT_HEADER, Box::new(
|
||||
Title::new(
|
||||
PropsBuilder::default().with_foreground(Color::White).with_texts(
|
||||
TextParts::new(Some(String::from(" _____ ____ ____ ____ \n|_ _|__ _ __ _ __ ___ / ___| / ___| _ \\ \n | |/ _ \\ '__| '_ ` _ \\\\___ \\| | | |_) |\n | | __/ | | | | | | |___) | |___| __/ \n |_|\\___|_| |_| |_| |_|____/ \\____|_| \n")), None)
|
||||
).bold().build()
|
||||
)
|
||||
));
|
||||
// Headers
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_H1,
|
||||
Box::new(Label::new(
|
||||
LabelPropsBuilder::default()
|
||||
.bold()
|
||||
.italic()
|
||||
.with_text(String::from("$ termscp"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_H2,
|
||||
Box::new(Label::new(
|
||||
LabelPropsBuilder::default()
|
||||
.bold()
|
||||
.italic()
|
||||
.with_text(format!("$ version {}", env!("CARGO_PKG_VERSION")))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Footer
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_FOOTER,
|
||||
Box::new(Text::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpanBuilder::new("Press ").bold().build(),
|
||||
TextSpanBuilder::new("<CTRL+H>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to show keybindings; ")
|
||||
.bold()
|
||||
.build(),
|
||||
TextSpanBuilder::new("<CTRL+C>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to enter setup").bold().build(),
|
||||
]),
|
||||
))
|
||||
Box::new(Span::new(
|
||||
SpanPropsBuilder::default()
|
||||
.with_spans(vec![
|
||||
TextSpanBuilder::new("Press ").bold().build(),
|
||||
TextSpanBuilder::new("<CTRL+H>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to show keybindings; ")
|
||||
.bold()
|
||||
.build(),
|
||||
TextSpanBuilder::new("<CTRL+C>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to enter setup").bold().build(),
|
||||
])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Get default protocol
|
||||
let default_protocol: FileTransferProtocol =
|
||||
match self.context.as_ref().unwrap().config_client.as_ref() {
|
||||
Some(cli) => cli.get_default_protocol(),
|
||||
None => FileTransferProtocol::Sftp,
|
||||
};
|
||||
// Protocol
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_PROTOCOL,
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightGreen)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen)
|
||||
.with_options(
|
||||
Some(String::from("Protocol")),
|
||||
vec![
|
||||
String::from("SFTP"),
|
||||
String::from("SCP"),
|
||||
String::from("FTP"),
|
||||
String::from("FTPS"),
|
||||
],
|
||||
)
|
||||
.with_value(Self::protocol_enum_to_opt(default_protocol))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -86,9 +130,10 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_ADDR,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_texts(TextParts::new(Some(String::from("Remote address")), None))
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow)
|
||||
.with_label(String::from("Remote address"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -96,31 +141,13 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_PORT,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::LightCyan)
|
||||
.with_texts(TextParts::new(Some(String::from("Port number")), None))
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan)
|
||||
.with_label(String::from("Port number"))
|
||||
.with_input(InputType::Number)
|
||||
.with_input_len(5)
|
||||
.with_value(PropValue::Str(String::from("22")))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Protocol
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_PROTOCOL,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Protocol")),
|
||||
Some(vec![
|
||||
TextSpan::from("SFTP"),
|
||||
TextSpan::from("SCP"),
|
||||
TextSpan::from("FTP"),
|
||||
TextSpan::from("FTPS"),
|
||||
]),
|
||||
))
|
||||
.with_value(Self::get_default_port_for_protocol(default_protocol).to_string())
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -128,9 +155,10 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_USERNAME,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::LightMagenta)
|
||||
.with_texts(TextParts::new(Some(String::from("Username")), None))
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta)
|
||||
.with_label(String::from("Username"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -138,9 +166,10 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_PASSWORD,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::LightBlue)
|
||||
.with_texts(TextParts::new(Some(String::from("Password")), None))
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue)
|
||||
.with_label(String::from("Password"))
|
||||
.with_input(InputType::Password)
|
||||
.build(),
|
||||
)),
|
||||
@@ -155,10 +184,16 @@ impl AuthActivity {
|
||||
{
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_NEW_VERSION,
|
||||
Box::new(Text::new(
|
||||
PropsBuilder::default()
|
||||
Box::new(Span::new(
|
||||
SpanPropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(format!("TermSCP {} is now available! Download it from <https://github.com/veeso/termscp/releases/latest>", version))])))
|
||||
.with_spans(
|
||||
vec![
|
||||
TextSpan::from("termscp "),
|
||||
TextSpanBuilder::new(version).underlined().bold().build(),
|
||||
TextSpan::from(" is now available! Download it from <https://github.com/veeso/termscp/releases/latest>")
|
||||
]
|
||||
)
|
||||
.build()
|
||||
))
|
||||
);
|
||||
@@ -167,10 +202,11 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_BOOKMARKS_LIST,
|
||||
Box::new(BookmarkList::new(
|
||||
PropsBuilder::default()
|
||||
BookmarkListPropsBuilder::default()
|
||||
.with_background(Color::LightGreen)
|
||||
.with_foreground(Color::Black)
|
||||
.with_texts(TextParts::new(Some(String::from("Bookmarks")), None))
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen)
|
||||
.with_bookmarks(Some(String::from("Bookmarks")), vec![])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -179,19 +215,17 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RECENTS_LIST,
|
||||
Box::new(BookmarkList::new(
|
||||
PropsBuilder::default()
|
||||
BookmarkListPropsBuilder::default()
|
||||
.with_background(Color::LightBlue)
|
||||
.with_foreground(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Recent connections")),
|
||||
None,
|
||||
))
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::LightBlue)
|
||||
.with_bookmarks(Some(String::from("Recent connections")), vec![])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
let _ = self.view_recent_connections();
|
||||
// Active address
|
||||
self.view.active(super::COMPONENT_INPUT_ADDR);
|
||||
// Active protocol
|
||||
self.view.active(super::COMPONENT_RADIO_PROTOCOL);
|
||||
}
|
||||
|
||||
/// ### view
|
||||
@@ -216,11 +250,12 @@ impl AuthActivity {
|
||||
let auth_chunks = Layout::default()
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(6), // header
|
||||
Constraint::Length(1), // h1
|
||||
Constraint::Length(1), // h2
|
||||
Constraint::Length(1), // Version
|
||||
Constraint::Length(3), // protocol
|
||||
Constraint::Length(3), // host
|
||||
Constraint::Length(3), // port
|
||||
Constraint::Length(3), // protocol
|
||||
Constraint::Length(3), // username
|
||||
Constraint::Length(3), // password
|
||||
Constraint::Length(3), // footer
|
||||
@@ -237,48 +272,50 @@ impl AuthActivity {
|
||||
// Render
|
||||
// Auth chunks
|
||||
self.view
|
||||
.render(super::COMPONENT_TEXT_HEADER, f, auth_chunks[0]);
|
||||
.render(super::COMPONENT_TEXT_H1, f, auth_chunks[0]);
|
||||
self.view
|
||||
.render(super::COMPONENT_TEXT_NEW_VERSION, f, auth_chunks[1]);
|
||||
.render(super::COMPONENT_TEXT_H2, f, auth_chunks[1]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_ADDR, f, auth_chunks[2]);
|
||||
.render(super::COMPONENT_TEXT_NEW_VERSION, f, auth_chunks[2]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_PORT, f, auth_chunks[3]);
|
||||
.render(super::COMPONENT_RADIO_PROTOCOL, f, auth_chunks[3]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_PROTOCOL, f, auth_chunks[4]);
|
||||
.render(super::COMPONENT_INPUT_ADDR, f, auth_chunks[4]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_USERNAME, f, auth_chunks[5]);
|
||||
.render(super::COMPONENT_INPUT_PORT, f, auth_chunks[5]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_PASSWORD, f, auth_chunks[6]);
|
||||
.render(super::COMPONENT_INPUT_USERNAME, f, auth_chunks[6]);
|
||||
self.view
|
||||
.render(super::COMPONENT_TEXT_FOOTER, f, auth_chunks[7]);
|
||||
.render(super::COMPONENT_INPUT_PASSWORD, f, auth_chunks[7]);
|
||||
self.view
|
||||
.render(super::COMPONENT_TEXT_FOOTER, f, auth_chunks[8]);
|
||||
// Bookmark chunks
|
||||
self.view
|
||||
.render(super::COMPONENT_BOOKMARKS_LIST, f, bookmark_chunks[0]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RECENTS_LIST, f, bookmark_chunks[1]);
|
||||
// Popups
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_TEXT_ERROR, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view.render(super::COMPONENT_RADIO_QUIT, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK)
|
||||
{
|
||||
if props.build().visible {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
@@ -286,11 +323,11 @@ impl AuthActivity {
|
||||
.render(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT)
|
||||
{
|
||||
if props.build().visible {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
@@ -298,19 +335,19 @@ impl AuthActivity {
|
||||
.render(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 50, 70);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view.render(super::COMPONENT_TEXT_HELP, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD)
|
||||
{
|
||||
if props.build().visible {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 20, 20);
|
||||
f.render_widget(Clear, popup);
|
||||
@@ -340,7 +377,7 @@ impl AuthActivity {
|
||||
///
|
||||
/// Make text span from bookmarks
|
||||
pub(super) fn view_bookmarks(&mut self) -> Option<(String, Msg)> {
|
||||
let bookmarks: Vec<TextSpan> = self
|
||||
let bookmarks: Vec<String> = self
|
||||
.bookmarks_list
|
||||
.iter()
|
||||
.map(|x| {
|
||||
@@ -350,33 +387,23 @@ impl AuthActivity {
|
||||
.unwrap()
|
||||
.get_bookmark(x)
|
||||
.unwrap();
|
||||
TextSpan::from(
|
||||
format!(
|
||||
"{} ({}://{}@{}:{})",
|
||||
x,
|
||||
entry.2.to_string().to_lowercase(),
|
||||
entry.3,
|
||||
entry.0,
|
||||
entry.1
|
||||
)
|
||||
.as_str(),
|
||||
format!(
|
||||
"{} ({}://{}@{}:{})",
|
||||
x,
|
||||
entry.2.to_string().to_lowercase(),
|
||||
entry.3,
|
||||
entry.0,
|
||||
entry.1
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
match self
|
||||
.view
|
||||
.get_props(super::COMPONENT_BOOKMARKS_LIST)
|
||||
.as_mut()
|
||||
{
|
||||
match self.view.get_props(super::COMPONENT_BOOKMARKS_LIST) {
|
||||
None => None,
|
||||
Some(props) => {
|
||||
let msg = self.view.update(
|
||||
super::COMPONENT_BOOKMARKS_LIST,
|
||||
props
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Bookmarks")),
|
||||
Some(bookmarks),
|
||||
))
|
||||
BookmarkListPropsBuilder::from(props)
|
||||
.with_bookmarks(Some(String::from("Bookmarks")), bookmarks)
|
||||
.build(),
|
||||
);
|
||||
msg
|
||||
@@ -388,7 +415,7 @@ impl AuthActivity {
|
||||
///
|
||||
/// View recent connections
|
||||
pub(super) fn view_recent_connections(&mut self) -> Option<(String, Msg)> {
|
||||
let bookmarks: Vec<TextSpan> = self
|
||||
let bookmarks: Vec<String> = self
|
||||
.recents_list
|
||||
.iter()
|
||||
.map(|x| {
|
||||
@@ -398,28 +425,23 @@ impl AuthActivity {
|
||||
.unwrap()
|
||||
.get_recent(x)
|
||||
.unwrap();
|
||||
TextSpan::from(
|
||||
format!(
|
||||
"{}://{}@{}:{}",
|
||||
entry.2.to_string().to_lowercase(),
|
||||
entry.3,
|
||||
entry.0,
|
||||
entry.1
|
||||
)
|
||||
.as_str(),
|
||||
|
||||
format!(
|
||||
"{}://{}@{}:{}",
|
||||
entry.2.to_string().to_lowercase(),
|
||||
entry.3,
|
||||
entry.0,
|
||||
entry.1
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
match self.view.get_props(super::COMPONENT_RECENTS_LIST).as_mut() {
|
||||
match self.view.get_props(super::COMPONENT_RECENTS_LIST) {
|
||||
None => None,
|
||||
Some(props) => {
|
||||
let msg = self.view.update(
|
||||
super::COMPONENT_RECENTS_LIST,
|
||||
props
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Recent connections")),
|
||||
Some(bookmarks),
|
||||
))
|
||||
BookmarkListPropsBuilder::from(props)
|
||||
.with_bookmarks(Some(String::from("Recent connections")), bookmarks)
|
||||
.build(),
|
||||
);
|
||||
msg
|
||||
@@ -437,10 +459,11 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_ERROR,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
MsgBoxPropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.with_borders(Borders::ALL, BorderType::Thick, Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.with_texts(None, vec![TextSpan::from(text)])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -462,14 +485,15 @@ impl AuthActivity {
|
||||
// Protocol
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_QUIT,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Quit TermSCP?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::Yellow)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_options(
|
||||
Some(String::from("Quit termscp?")),
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -489,15 +513,16 @@ impl AuthActivity {
|
||||
pub(super) fn mount_bookmark_del_dialog(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::Yellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
|
||||
.with_options(
|
||||
Some(String::from("Delete bookmark?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.with_value(1)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -520,15 +545,16 @@ impl AuthActivity {
|
||||
pub(super) fn mount_recent_del_dialog(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::Yellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
|
||||
.with_options(
|
||||
Some(String::from("Delete bookmark?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.with_value(1)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -550,27 +576,31 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_BOOKMARK_NAME,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::LightCyan)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Save bookmark as...")),
|
||||
None,
|
||||
))
|
||||
//.with_borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
|
||||
.with_label(String::from("Save bookmark as..."))
|
||||
.with_borders(
|
||||
Borders::TOP | Borders::RIGHT | Borders::LEFT,
|
||||
BorderType::Rounded,
|
||||
Color::Reset,
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
//.with_borders(Borders::BOTTOM | Borders::RIGHT | Borders::LEFT)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::Red)
|
||||
.with_borders(
|
||||
Borders::BOTTOM | Borders::RIGHT | Borders::LEFT,
|
||||
BorderType::Rounded,
|
||||
Color::Reset,
|
||||
)
|
||||
.with_options(
|
||||
Some(String::from("Save password?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
//.with_value(PropValue::Unsigned(1))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -593,8 +623,9 @@ impl AuthActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_HELP,
|
||||
Box::new(Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
TablePropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_table(
|
||||
Some(String::from("Help")),
|
||||
TableBuilder::default()
|
||||
.add_col(
|
||||
@@ -603,7 +634,7 @@ impl AuthActivity {
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Quit TermSCP"))
|
||||
.add_col(TextSpan::from(" Quit termscp"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<TAB>")
|
||||
@@ -661,7 +692,7 @@ impl AuthActivity {
|
||||
)
|
||||
.add_col(TextSpan::from(" Save bookmark"))
|
||||
.build(),
|
||||
))
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -680,32 +711,46 @@ impl AuthActivity {
|
||||
///
|
||||
/// Collect input values from view
|
||||
pub(super) fn get_input(&self) -> (String, u16, FileTransferProtocol, String, String) {
|
||||
let addr: String = match self.view.get_value(super::COMPONENT_INPUT_ADDR) {
|
||||
Some(Payload::Text(a)) => a,
|
||||
_ => String::new(),
|
||||
};
|
||||
let port: u16 = match self.view.get_value(super::COMPONENT_INPUT_PORT) {
|
||||
Some(Payload::Unsigned(p)) => p as u16,
|
||||
_ => 0,
|
||||
};
|
||||
let protocol: FileTransferProtocol =
|
||||
match self.view.get_value(super::COMPONENT_RADIO_PROTOCOL) {
|
||||
Some(Payload::Unsigned(p)) => match p {
|
||||
1 => FileTransferProtocol::Scp,
|
||||
2 => FileTransferProtocol::Ftp(false),
|
||||
3 => FileTransferProtocol::Ftp(true),
|
||||
_ => FileTransferProtocol::Sftp,
|
||||
},
|
||||
_ => FileTransferProtocol::Sftp,
|
||||
};
|
||||
let username: String = match self.view.get_value(super::COMPONENT_INPUT_USERNAME) {
|
||||
Some(Payload::Text(a)) => a,
|
||||
_ => String::new(),
|
||||
};
|
||||
let password: String = match self.view.get_value(super::COMPONENT_INPUT_PASSWORD) {
|
||||
Some(Payload::Text(a)) => a,
|
||||
_ => String::new(),
|
||||
};
|
||||
let addr: String = self.get_input_addr();
|
||||
let port: u16 = self.get_input_port();
|
||||
let protocol: FileTransferProtocol = self.get_input_protocol();
|
||||
let username: String = self.get_input_username();
|
||||
let password: String = self.get_input_password();
|
||||
(addr, port, protocol, username, password)
|
||||
}
|
||||
|
||||
pub(super) fn get_input_addr(&self) -> String {
|
||||
match self.view.get_state(super::COMPONENT_INPUT_ADDR) {
|
||||
Some(Payload::One(Value::Str(x))) => x,
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_input_port(&self) -> u16 {
|
||||
match self.view.get_state(super::COMPONENT_INPUT_PORT) {
|
||||
Some(Payload::One(Value::Usize(x))) => x as u16,
|
||||
_ => Self::get_default_port_for_protocol(FileTransferProtocol::Sftp),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_input_protocol(&self) -> FileTransferProtocol {
|
||||
match self.view.get_state(super::COMPONENT_RADIO_PROTOCOL) {
|
||||
Some(Payload::One(Value::Usize(x))) => Self::protocol_opt_to_enum(x),
|
||||
_ => FileTransferProtocol::Sftp,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_input_username(&self) -> String {
|
||||
match self.view.get_state(super::COMPONENT_INPUT_USERNAME) {
|
||||
Some(Payload::One(Value::Str(x))) => x,
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get_input_password(&self) -> String {
|
||||
match self.view.get_state(super::COMPONENT_INPUT_PASSWORD) {
|
||||
Some(Payload::One(Value::Str(x))) => x,
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/ui/activities/filetransfer/actions/change_dir.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, FsEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### action_enter_local_dir
|
||||
///
|
||||
/// Enter a directory on local host from entry
|
||||
/// Return true whether the directory changed
|
||||
pub(crate) fn action_enter_local_dir(&mut self, entry: FsEntry, block_sync: bool) -> bool {
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.local_changedir(dir.abs_path.as_path(), true);
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_change_remote_dir(dir.name, true);
|
||||
}
|
||||
true
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
match &file.symlink {
|
||||
Some(symlink_entry) => {
|
||||
// If symlink and is directory, point to symlink
|
||||
match &**symlink_entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.local_changedir(dir.abs_path.as_path(), true);
|
||||
// Check whether to sync
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_change_remote_dir(dir.name.clone(), true);
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_enter_remote_dir
|
||||
///
|
||||
/// Enter a directory on local host from entry
|
||||
/// Return true whether the directory changed
|
||||
pub(crate) fn action_enter_remote_dir(&mut self, entry: FsEntry, block_sync: bool) -> bool {
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.remote_changedir(dir.abs_path.as_path(), true);
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_change_local_dir(dir.name, true);
|
||||
}
|
||||
true
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
match &file.symlink {
|
||||
Some(symlink_entry) => {
|
||||
// If symlink and is directory, point to symlink
|
||||
match &**symlink_entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.remote_changedir(dir.abs_path.as_path(), true);
|
||||
// Check whether to sync
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_change_local_dir(dir.name.clone(), true);
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_change_local_dir
|
||||
///
|
||||
/// Change local directory reading value from input
|
||||
pub(crate) fn action_change_local_dir(&mut self, input: String, block_sync: bool) {
|
||||
let dir_path: PathBuf = self.local_to_abs_path(PathBuf::from(input.as_str()).as_path());
|
||||
self.local_changedir(dir_path.as_path(), true);
|
||||
// Check whether to sync
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_change_remote_dir(input, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_change_remote_dir
|
||||
///
|
||||
/// Change remote directory reading value from input
|
||||
pub(crate) fn action_change_remote_dir(&mut self, input: String, block_sync: bool) {
|
||||
let dir_path: PathBuf = self.remote_to_abs_path(PathBuf::from(input.as_str()).as_path());
|
||||
self.remote_changedir(dir_path.as_path(), true);
|
||||
// Check whether to sync
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_change_local_dir(input, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_go_to_previous_local_dir
|
||||
///
|
||||
/// Go to previous directory from localhost
|
||||
pub(crate) fn action_go_to_previous_local_dir(&mut self, block_sync: bool) {
|
||||
if let Some(d) = self.local_mut().popd() {
|
||||
self.local_changedir(d.as_path(), false);
|
||||
// Check whether to sync
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_go_to_previous_remote_dir(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_go_to_previous_remote_dir
|
||||
///
|
||||
/// Go to previous directory from remote host
|
||||
pub(crate) fn action_go_to_previous_remote_dir(&mut self, block_sync: bool) {
|
||||
if let Some(d) = self.remote_mut().popd() {
|
||||
self.remote_changedir(d.as_path(), false);
|
||||
// Check whether to sync
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_go_to_previous_local_dir(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_go_to_local_upper_dir
|
||||
///
|
||||
/// Go to upper directory on local host
|
||||
pub(crate) fn action_go_to_local_upper_dir(&mut self, block_sync: bool) {
|
||||
// Get pwd
|
||||
let path: PathBuf = self.local().wrkdir.clone();
|
||||
// Go to parent directory
|
||||
if let Some(parent) = path.as_path().parent() {
|
||||
self.local_changedir(parent, true);
|
||||
// If sync is enabled update remote too
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_go_to_remote_upper_dir(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #### action_go_to_remote_upper_dir
|
||||
///
|
||||
/// Go to upper directory on remote host
|
||||
pub(crate) fn action_go_to_remote_upper_dir(&mut self, block_sync: bool) {
|
||||
// Get pwd
|
||||
let path: PathBuf = self.remote().wrkdir.clone();
|
||||
// Go to parent directory
|
||||
if let Some(parent) = path.as_path().parent() {
|
||||
self.remote_changedir(parent, true);
|
||||
// If sync is enabled update local too
|
||||
if self.browser.sync_browsing && !block_sync {
|
||||
self.action_go_to_local_upper_dir(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/ui/activities/filetransfer/actions/copy.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
extern crate tempfile;
|
||||
// locals
|
||||
use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry};
|
||||
use crate::filetransfer::FileTransferErrorType;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### action_local_copy
|
||||
///
|
||||
/// Copy file on local
|
||||
pub(crate) fn action_local_copy(&mut self, input: String) {
|
||||
match self.get_local_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
let dest_path: PathBuf = PathBuf::from(input);
|
||||
self.local_copy_file(&entry, dest_path.as_path());
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// Try to copy each file to Input/{FILE_NAME}
|
||||
let base_path: PathBuf = PathBuf::from(input);
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
let mut dest_path: PathBuf = base_path.clone();
|
||||
dest_path.push(entry.get_name());
|
||||
self.local_copy_file(entry, dest_path.as_path());
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_remote_copy
|
||||
///
|
||||
/// Copy file on remote
|
||||
pub(crate) fn action_remote_copy(&mut self, input: String) {
|
||||
match self.get_remote_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
let dest_path: PathBuf = PathBuf::from(input);
|
||||
self.remote_copy_file(&entry, dest_path.as_path());
|
||||
// Reload entries
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// Try to copy each file to Input/{FILE_NAME}
|
||||
let base_path: PathBuf = PathBuf::from(input);
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
let mut dest_path: PathBuf = base_path.clone();
|
||||
dest_path.push(entry.get_name());
|
||||
self.remote_copy_file(entry, dest_path.as_path());
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn local_copy_file(&mut self, entry: &FsEntry, dest: &Path) {
|
||||
match self.host.copy(entry, dest) {
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Copied \"{}\" to \"{}\"",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not copy \"{}\" to \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_copy_file(&mut self, entry: &FsEntry, dest: &Path) {
|
||||
match self.client.as_mut().copy(entry, dest) {
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Copied \"{}\" to \"{}\"",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(err) => match err.kind() {
|
||||
FileTransferErrorType::UnsupportedFeature => {
|
||||
// If copy is not supported, perform the tricky copy
|
||||
self.tricky_copy(entry, dest);
|
||||
}
|
||||
_ => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not copy \"{}\" to \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/ui/activities/filetransfer/actions/delete.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry};
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_delete(&mut self) {
|
||||
match self.get_local_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
// Delete file
|
||||
self.local_remove_file(&entry);
|
||||
// Reload
|
||||
self.reload_local_dir();
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
// Delete file
|
||||
self.local_remove_file(entry);
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_delete(&mut self) {
|
||||
match self.get_remote_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
// Delete file
|
||||
self.remote_remove_file(&entry);
|
||||
// Reload
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
// Delete file
|
||||
self.remote_remove_file(entry);
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local_remove_file(&mut self, entry: &FsEntry) {
|
||||
match self.host.remove(&entry) {
|
||||
Ok(_) => {
|
||||
// Log
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Removed file \"{}\"", entry.get_abs_path().display()),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not delete file \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remote_remove_file(&mut self, entry: &FsEntry) {
|
||||
match self.client.remove(&entry) {
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Removed file \"{}\"", entry.get_abs_path().display()),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not delete file \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/ui/activities/filetransfer/actions/edit.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry};
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_edit_local_file(&mut self) {
|
||||
let entries: Vec<FsEntry> = match self.get_local_selected_entries() {
|
||||
SelectedEntry::One(entry) => vec![entry],
|
||||
SelectedEntry::Many(entries) => entries,
|
||||
SelectedEntry::None => vec![],
|
||||
};
|
||||
// Edit all entries
|
||||
for entry in entries.iter() {
|
||||
// Check if file
|
||||
if entry.is_file() {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Opening file \"{}\"...", entry.get_abs_path().display()),
|
||||
);
|
||||
// Edit file
|
||||
if let Err(err) = self.edit_local_file(entry.get_abs_path().as_path()) {
|
||||
self.log_and_alert(LogLevel::Error, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
|
||||
pub(crate) fn action_edit_remote_file(&mut self) {
|
||||
let entries: Vec<FsEntry> = match self.get_remote_selected_entries() {
|
||||
SelectedEntry::One(entry) => vec![entry],
|
||||
SelectedEntry::Many(entries) => entries,
|
||||
SelectedEntry::None => vec![],
|
||||
};
|
||||
// Edit all entries
|
||||
for entry in entries.iter() {
|
||||
// Check if file
|
||||
if let FsEntry::File(file) = entry {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Opening file \"{}\"...", entry.get_abs_path().display()),
|
||||
);
|
||||
// Edit file
|
||||
if let Err(err) = self.edit_remote_file(&file) {
|
||||
self.log_and_alert(LogLevel::Error, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
}
|
||||
66
src/ui/activities/filetransfer/actions/exec.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, LogLevel};
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_exec(&mut self, input: String) {
|
||||
match self.host.exec(input.as_str()) {
|
||||
Ok(output) => {
|
||||
// Reload files
|
||||
self.log(LogLevel::Info, format!("\"{}\": {}", input, output));
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not execute command \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_exec(&mut self, input: String) {
|
||||
match self.client.as_mut().exec(input.as_str()) {
|
||||
Ok(output) => {
|
||||
// Reload files
|
||||
self.log(LogLevel::Info, format!("\"{}\": {}", input, output));
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not execute command \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/ui/activities/filetransfer/actions/find.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::super::browser::FileExplorerTab;
|
||||
use super::{FileTransferActivity, FsEntry, SelectedEntry};
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
|
||||
match self.host.find(input.as_str()) {
|
||||
Ok(entries) => Ok(entries),
|
||||
Err(err) => Err(format!("Could not search for files: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
|
||||
match self.client.as_mut().find(input.as_str()) {
|
||||
Ok(entries) => Ok(entries),
|
||||
Err(err) => Err(format!("Could not search for files: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_find_changedir(&mut self) {
|
||||
// Match entry
|
||||
if let SelectedEntry::One(entry) = self.get_found_selected_entries() {
|
||||
// Get path: if a directory, use directory path; if it is a File, get parent path
|
||||
let path: PathBuf = match entry {
|
||||
FsEntry::Directory(dir) => dir.abs_path,
|
||||
FsEntry::File(file) => match file.abs_path.parent() {
|
||||
None => PathBuf::from("."),
|
||||
Some(p) => p.to_path_buf(),
|
||||
},
|
||||
};
|
||||
// Change directory
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
|
||||
self.local_changedir(path.as_path(), true)
|
||||
}
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
|
||||
self.remote_changedir(path.as_path(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_find_transfer(&mut self, save_as: Option<String>) {
|
||||
let wrkdir: PathBuf = match self.browser.tab() {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => self.remote().wrkdir.clone(),
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => self.local().wrkdir.clone(),
|
||||
};
|
||||
match self.get_found_selected_entries() {
|
||||
SelectedEntry::One(entry) => match self.browser.tab() {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
|
||||
self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), save_as);
|
||||
}
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
|
||||
self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), save_as);
|
||||
}
|
||||
},
|
||||
SelectedEntry::Many(entries) => {
|
||||
// In case of selection: save multiple files in wrkdir/input
|
||||
let mut dest_path: PathBuf = wrkdir;
|
||||
if let Some(save_as) = save_as {
|
||||
dest_path.push(save_as);
|
||||
}
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
|
||||
self.filetransfer_send(
|
||||
&entry.get_realfile(),
|
||||
dest_path.as_path(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
|
||||
self.filetransfer_recv(
|
||||
&entry.get_realfile(),
|
||||
dest_path.as_path(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_find_delete(&mut self) {
|
||||
match self.get_found_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
// Delete file
|
||||
self.remove_found_file(&entry);
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
// Delete file
|
||||
self.remove_found_file(entry);
|
||||
}
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_found_file(&mut self, entry: &FsEntry) {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
|
||||
self.local_remove_file(entry);
|
||||
}
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
|
||||
self.remote_remove_file(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/ui/activities/filetransfer/actions/mkdir.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, LogLevel};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_mkdir(&mut self, input: String) {
|
||||
match self.host.mkdir(PathBuf::from(input.as_str()).as_path()) {
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
self.log(LogLevel::Info, format!("Created directory \"{}\"", input));
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create directory \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn action_remote_mkdir(&mut self, input: String) {
|
||||
match self
|
||||
.client
|
||||
.as_mut()
|
||||
.mkdir(PathBuf::from(input.as_str()).as_path())
|
||||
{
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
self.log(LogLevel::Info, format!("Created directory \"{}\"", input));
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create directory \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/ui/activities/filetransfer/actions/mod.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
pub(self) use super::{FileTransferActivity, FsEntry, LogLevel};
|
||||
use tuirealm::{Payload, Value};
|
||||
|
||||
// actions
|
||||
pub(crate) mod change_dir;
|
||||
pub(crate) mod copy;
|
||||
pub(crate) mod delete;
|
||||
pub(crate) mod edit;
|
||||
pub(crate) mod exec;
|
||||
pub(crate) mod find;
|
||||
pub(crate) mod mkdir;
|
||||
pub(crate) mod newfile;
|
||||
pub(crate) mod rename;
|
||||
pub(crate) mod save;
|
||||
|
||||
pub(crate) enum SelectedEntry {
|
||||
One(FsEntry),
|
||||
Many(Vec<FsEntry>),
|
||||
None,
|
||||
}
|
||||
|
||||
enum SelectedEntryIndex {
|
||||
One(usize),
|
||||
Many(Vec<usize>),
|
||||
None,
|
||||
}
|
||||
|
||||
impl From<Option<&FsEntry>> for SelectedEntry {
|
||||
fn from(opt: Option<&FsEntry>) -> Self {
|
||||
match opt {
|
||||
Some(e) => SelectedEntry::One(e.clone()),
|
||||
None => SelectedEntry::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec<&FsEntry>> for SelectedEntry {
|
||||
fn from(files: Vec<&FsEntry>) -> Self {
|
||||
SelectedEntry::Many(files.into_iter().cloned().collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### get_local_selected_entries
|
||||
///
|
||||
/// Get local file entry
|
||||
pub(crate) fn get_local_selected_entries(&self) -> SelectedEntry {
|
||||
match self.get_selected_index(super::COMPONENT_EXPLORER_LOCAL) {
|
||||
SelectedEntryIndex::One(idx) => SelectedEntry::from(self.local().get(idx)),
|
||||
SelectedEntryIndex::Many(files) => {
|
||||
let files: Vec<&FsEntry> = files
|
||||
.iter()
|
||||
.map(|x| self.local().get(*x)) // Usize to Option<FsEntry>
|
||||
.filter(|x| x.is_some()) // Get only some values
|
||||
.map(|x| x.unwrap()) // Option to FsEntry
|
||||
.collect();
|
||||
SelectedEntry::from(files)
|
||||
}
|
||||
SelectedEntryIndex::None => SelectedEntry::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_remote_selected_entries
|
||||
///
|
||||
/// Get remote file entry
|
||||
pub(crate) fn get_remote_selected_entries(&self) -> SelectedEntry {
|
||||
match self.get_selected_index(super::COMPONENT_EXPLORER_REMOTE) {
|
||||
SelectedEntryIndex::One(idx) => SelectedEntry::from(self.remote().get(idx)),
|
||||
SelectedEntryIndex::Many(files) => {
|
||||
let files: Vec<&FsEntry> = files
|
||||
.iter()
|
||||
.map(|x| self.remote().get(*x)) // Usize to Option<FsEntry>
|
||||
.filter(|x| x.is_some()) // Get only some values
|
||||
.map(|x| x.unwrap()) // Option to FsEntry
|
||||
.collect();
|
||||
SelectedEntry::from(files)
|
||||
}
|
||||
SelectedEntryIndex::None => SelectedEntry::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_remote_selected_entries
|
||||
///
|
||||
/// Get remote file entry
|
||||
pub(crate) fn get_found_selected_entries(&self) -> SelectedEntry {
|
||||
match self.get_selected_index(super::COMPONENT_EXPLORER_FIND) {
|
||||
SelectedEntryIndex::One(idx) => {
|
||||
SelectedEntry::from(self.found().as_ref().unwrap().get(idx))
|
||||
}
|
||||
SelectedEntryIndex::Many(files) => {
|
||||
let files: Vec<&FsEntry> = files
|
||||
.iter()
|
||||
.map(|x| self.found().as_ref().unwrap().get(*x)) // Usize to Option<FsEntry>
|
||||
.filter(|x| x.is_some()) // Get only some values
|
||||
.map(|x| x.unwrap()) // Option to FsEntry
|
||||
.collect();
|
||||
SelectedEntry::from(files)
|
||||
}
|
||||
SelectedEntryIndex::None => SelectedEntry::None,
|
||||
}
|
||||
}
|
||||
|
||||
// -- private
|
||||
|
||||
fn get_selected_index(&self, component: &str) -> SelectedEntryIndex {
|
||||
match self.view.get_state(component) {
|
||||
Some(Payload::One(Value::Usize(idx))) => SelectedEntryIndex::One(idx),
|
||||
Some(Payload::Vec(files)) => {
|
||||
let list: Vec<usize> = files
|
||||
.iter()
|
||||
.map(|x| match x {
|
||||
Value::Usize(v) => *v,
|
||||
_ => 0,
|
||||
})
|
||||
.collect();
|
||||
SelectedEntryIndex::Many(list)
|
||||
}
|
||||
_ => SelectedEntryIndex::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/ui/activities/filetransfer/actions/newfile.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, FsEntry, LogLevel};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_newfile(&mut self, input: String) {
|
||||
// Check if file exists
|
||||
let mut file_exists: bool = false;
|
||||
for file in self.local().iter_files_all() {
|
||||
if input == file.get_name() {
|
||||
file_exists = true;
|
||||
}
|
||||
}
|
||||
if file_exists {
|
||||
self.log_and_alert(
|
||||
LogLevel::Warn,
|
||||
format!("File \"{}\" already exists", input,),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Create file
|
||||
let file_path: PathBuf = PathBuf::from(input.as_str());
|
||||
if let Err(err) = self.host.open_file_write(file_path.as_path()) {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create file \"{}\": {}", file_path.display(), err),
|
||||
);
|
||||
} else {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created file \"{}\"", file_path.display()),
|
||||
);
|
||||
}
|
||||
// Reload files
|
||||
self.reload_local_dir();
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_newfile(&mut self, input: String) {
|
||||
// Check if file exists
|
||||
let mut file_exists: bool = false;
|
||||
for file in self.remote().iter_files_all() {
|
||||
if input == file.get_name() {
|
||||
file_exists = true;
|
||||
}
|
||||
}
|
||||
if file_exists {
|
||||
self.log_and_alert(
|
||||
LogLevel::Warn,
|
||||
format!("File \"{}\" already exists", input,),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Get path on remote
|
||||
let file_path: PathBuf = PathBuf::from(input.as_str());
|
||||
// Create file (on local)
|
||||
match tempfile::NamedTempFile::new() {
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create tempfile: {}", err),
|
||||
),
|
||||
Ok(tfile) => {
|
||||
// Stat tempfile
|
||||
let local_file: FsEntry = match self.host.stat(tfile.path()) {
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not stat tempfile: {}", err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(f) => f,
|
||||
};
|
||||
if let FsEntry::File(local_file) = local_file {
|
||||
// Create file
|
||||
match self.client.send_file(&local_file, file_path.as_path()) {
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create file \"{}\": {}", file_path.display(), err),
|
||||
),
|
||||
Ok(writer) => {
|
||||
// Finalize write
|
||||
if let Err(err) = self.client.on_sent(writer) {
|
||||
self.log_and_alert(
|
||||
LogLevel::Warn,
|
||||
format!("Could not finalize file: {}", err),
|
||||
);
|
||||
} else {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created file \"{}\"", file_path.display()),
|
||||
);
|
||||
}
|
||||
// Reload files
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/ui/activities/filetransfer/actions/rename.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_rename(&mut self, input: String) {
|
||||
match self.get_local_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
let dest_path: PathBuf = PathBuf::from(input);
|
||||
self.local_rename_file(&entry, dest_path.as_path());
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// Try to copy each file to Input/{FILE_NAME}
|
||||
let base_path: PathBuf = PathBuf::from(input);
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
let mut dest_path: PathBuf = base_path.clone();
|
||||
dest_path.push(entry.get_name());
|
||||
self.local_rename_file(entry, dest_path.as_path());
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_local_dir();
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_rename(&mut self, input: String) {
|
||||
match self.get_remote_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
let dest_path: PathBuf = PathBuf::from(input);
|
||||
self.remote_rename_file(&entry, dest_path.as_path());
|
||||
// Reload entries
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// Try to copy each file to Input/{FILE_NAME}
|
||||
let base_path: PathBuf = PathBuf::from(input);
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
let mut dest_path: PathBuf = base_path.clone();
|
||||
dest_path.push(entry.get_name());
|
||||
self.remote_rename_file(entry, dest_path.as_path());
|
||||
}
|
||||
// Reload entries
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn local_rename_file(&mut self, entry: &FsEntry, dest: &Path) {
|
||||
match self.host.rename(entry, dest) {
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Moved \"{}\" to \"{}\"",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not move \"{}\" to \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_rename_file(&mut self, entry: &FsEntry, dest: &Path) {
|
||||
match self.client.as_mut().rename(entry, dest) {
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Moved \"{}\" to \"{}\"",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display()
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not move \"{}\" to \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
dest.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/ui/activities/filetransfer/actions/save.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileTransferActivity, SelectedEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_saveas(&mut self, input: String) {
|
||||
self.action_local_send_file(Some(input));
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_saveas(&mut self, input: String) {
|
||||
self.action_remote_recv_file(Some(input));
|
||||
}
|
||||
|
||||
pub(crate) fn action_local_send(&mut self) {
|
||||
self.action_local_send_file(None);
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_recv(&mut self) {
|
||||
self.action_remote_recv_file(None);
|
||||
}
|
||||
|
||||
fn action_local_send_file(&mut self, save_as: Option<String>) {
|
||||
let wrkdir: PathBuf = self.remote().wrkdir.clone();
|
||||
match self.get_local_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), save_as);
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// In case of selection: save multiple files in wrkdir/input
|
||||
let mut dest_path: PathBuf = wrkdir;
|
||||
if let Some(save_as) = save_as {
|
||||
dest_path.push(save_as);
|
||||
}
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
self.filetransfer_send(&entry.get_realfile(), dest_path.as_path(), None);
|
||||
}
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_remote_recv_file(&mut self, save_as: Option<String>) {
|
||||
let wrkdir: PathBuf = self.local().wrkdir.clone();
|
||||
match self.get_remote_selected_entries() {
|
||||
SelectedEntry::One(entry) => {
|
||||
self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), save_as);
|
||||
}
|
||||
SelectedEntry::Many(entries) => {
|
||||
// In case of selection: save multiple files in wrkdir/input
|
||||
let mut dest_path: PathBuf = wrkdir;
|
||||
if let Some(save_as) = save_as {
|
||||
dest_path.push(save_as);
|
||||
}
|
||||
// Iter files
|
||||
for entry in entries.iter() {
|
||||
self.filetransfer_recv(&entry.get_realfile(), dest_path.as_path(), None);
|
||||
}
|
||||
}
|
||||
SelectedEntry::None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/ui/activities/filetransfer/lib/browser.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
use crate::fs::explorer::{builder::FileExplorerBuilder, FileExplorer, FileSorting, GroupDirs};
|
||||
use crate::fs::FsEntry;
|
||||
use crate::system::config_client::ConfigClient;
|
||||
|
||||
/// ## FileExplorerTab
|
||||
///
|
||||
/// File explorer tab
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum FileExplorerTab {
|
||||
Local,
|
||||
Remote,
|
||||
FindLocal, // Find result tab
|
||||
FindRemote, // Find result tab
|
||||
}
|
||||
|
||||
/// ## Browser
|
||||
///
|
||||
/// Browser contains the browser options
|
||||
pub struct Browser {
|
||||
local: FileExplorer, // Local File explorer state
|
||||
remote: FileExplorer, // Remote File explorer state
|
||||
found: Option<FileExplorer>, // File explorer for find result
|
||||
tab: FileExplorerTab, // Current selected tab
|
||||
pub sync_browsing: bool,
|
||||
}
|
||||
|
||||
impl Browser {
|
||||
/// ### new
|
||||
///
|
||||
/// Build a new `Browser` struct
|
||||
pub fn new(cli: Option<&ConfigClient>) -> Self {
|
||||
Self {
|
||||
local: Self::build_local_explorer(cli),
|
||||
remote: Self::build_remote_explorer(cli),
|
||||
found: None,
|
||||
tab: FileExplorerTab::Local,
|
||||
sync_browsing: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local(&self) -> &FileExplorer {
|
||||
&self.local
|
||||
}
|
||||
|
||||
pub fn local_mut(&mut self) -> &mut FileExplorer {
|
||||
&mut self.local
|
||||
}
|
||||
|
||||
pub fn remote(&self) -> &FileExplorer {
|
||||
&self.remote
|
||||
}
|
||||
|
||||
pub fn remote_mut(&mut self) -> &mut FileExplorer {
|
||||
&mut self.remote
|
||||
}
|
||||
|
||||
pub fn found(&self) -> Option<&FileExplorer> {
|
||||
self.found.as_ref()
|
||||
}
|
||||
|
||||
pub fn found_mut(&mut self) -> Option<&mut FileExplorer> {
|
||||
self.found.as_mut()
|
||||
}
|
||||
|
||||
pub fn set_found(&mut self, files: Vec<FsEntry>) {
|
||||
let mut explorer = Self::build_found_explorer();
|
||||
explorer.set_files(files);
|
||||
self.found = Some(explorer);
|
||||
}
|
||||
|
||||
pub fn del_found(&mut self) {
|
||||
self.found = None;
|
||||
}
|
||||
|
||||
pub fn tab(&self) -> FileExplorerTab {
|
||||
self.tab
|
||||
}
|
||||
|
||||
/// ### change_tab
|
||||
///
|
||||
/// Update tab value
|
||||
pub fn change_tab(&mut self, tab: FileExplorerTab) {
|
||||
self.tab = tab;
|
||||
}
|
||||
|
||||
/// ### toggle_sync_browsing
|
||||
///
|
||||
/// Invert the current state for the sync browsing
|
||||
pub fn toggle_sync_browsing(&mut self) {
|
||||
self.sync_browsing = !self.sync_browsing;
|
||||
}
|
||||
|
||||
/// ### build_local_explorer
|
||||
///
|
||||
/// Build a file explorer with local host setup
|
||||
pub fn build_local_explorer(cli: Option<&ConfigClient>) -> FileExplorer {
|
||||
let mut builder = Self::build_explorer(cli);
|
||||
if let Some(cli) = cli {
|
||||
builder.with_formatter(cli.get_local_file_fmt().as_deref());
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// ### build_remote_explorer
|
||||
///
|
||||
/// Build a file explorer with remote host setup
|
||||
pub fn build_remote_explorer(cli: Option<&ConfigClient>) -> FileExplorer {
|
||||
let mut builder = Self::build_explorer(cli);
|
||||
if let Some(cli) = cli {
|
||||
builder.with_formatter(cli.get_remote_file_fmt().as_deref());
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
/// ### build_explorer
|
||||
///
|
||||
/// Build explorer reading configuration from `ConfigClient`
|
||||
fn build_explorer(cli: Option<&ConfigClient>) -> FileExplorerBuilder {
|
||||
let mut builder: FileExplorerBuilder = FileExplorerBuilder::new();
|
||||
// Set common keys
|
||||
builder
|
||||
.with_file_sorting(FileSorting::ByName)
|
||||
.with_stack_size(16);
|
||||
match &cli {
|
||||
Some(cli) => {
|
||||
builder // Build according to current configuration
|
||||
.with_group_dirs(cli.get_group_dirs())
|
||||
.with_hidden_files(cli.get_show_hidden_files());
|
||||
}
|
||||
None => {
|
||||
builder // Build default
|
||||
.with_group_dirs(Some(GroupDirs::First));
|
||||
}
|
||||
};
|
||||
builder
|
||||
}
|
||||
|
||||
/// ### build_found_explorer
|
||||
///
|
||||
/// Build explorer reading from `ConfigClient`, for found result (has some differences)
|
||||
fn build_found_explorer() -> FileExplorer {
|
||||
FileExplorerBuilder::new()
|
||||
.with_file_sorting(FileSorting::ByName)
|
||||
.with_group_dirs(Some(GroupDirs::First))
|
||||
.with_hidden_files(true)
|
||||
.with_stack_size(0)
|
||||
.with_formatter(Some("{NAME} {SYMLINK}"))
|
||||
.build()
|
||||
}
|
||||
}
|
||||
29
src/ui/activities/filetransfer/lib/mod.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod transfer;
|
||||
259
src/ui/activities/filetransfer/lib/transfer.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
use bytesize::ByteSize;
|
||||
use std::fmt;
|
||||
use std::time::Instant;
|
||||
|
||||
/// ### TransferStates
|
||||
///
|
||||
/// TransferStates contains the states related to the transfer process
|
||||
pub struct TransferStates {
|
||||
aborted: bool, // Describes whether the transfer process has been aborted
|
||||
pub full: ProgressStates, // full transfer states
|
||||
pub partial: ProgressStates, // Partial transfer states
|
||||
}
|
||||
|
||||
/// ### ProgressStates
|
||||
///
|
||||
/// Progress states describes the states for the progress of a single transfer part
|
||||
pub struct ProgressStates {
|
||||
started: Instant,
|
||||
total: usize,
|
||||
written: usize,
|
||||
}
|
||||
|
||||
impl Default for TransferStates {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransferStates {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new transfer states
|
||||
pub fn new() -> TransferStates {
|
||||
TransferStates {
|
||||
aborted: false,
|
||||
full: ProgressStates::default(),
|
||||
partial: ProgressStates::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### reset
|
||||
///
|
||||
/// Re-intiialize transfer states
|
||||
pub fn reset(&mut self) {
|
||||
self.aborted = false;
|
||||
}
|
||||
|
||||
/// ### abort
|
||||
///
|
||||
/// Set aborted to true
|
||||
pub fn abort(&mut self) {
|
||||
self.aborted = true;
|
||||
}
|
||||
|
||||
/// ### aborted
|
||||
///
|
||||
/// Returns whether transfer has been aborted
|
||||
pub fn aborted(&self) -> bool {
|
||||
self.aborted
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProgressStates {
|
||||
fn default() -> Self {
|
||||
ProgressStates {
|
||||
started: Instant::now(),
|
||||
written: 0,
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ProgressStates {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let eta: String = match self.calc_eta() {
|
||||
0 => String::from("--:--"),
|
||||
seconds => format!(
|
||||
"{:0width$}:{:0width$}",
|
||||
(seconds / 60),
|
||||
(seconds % 60),
|
||||
width = 2
|
||||
),
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"{:.2}% - ETA {} ({}/s)",
|
||||
self.calc_progress_percentage(),
|
||||
eta,
|
||||
ByteSize(self.calc_bytes_per_second())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProgressStates {
|
||||
/// ### init
|
||||
///
|
||||
/// Initialize a new Progress State
|
||||
pub fn init(&mut self, sz: usize) {
|
||||
self.started = Instant::now();
|
||||
self.total = sz;
|
||||
self.written = 0;
|
||||
}
|
||||
|
||||
/// ### update_progress
|
||||
///
|
||||
/// Update progress state
|
||||
pub fn update_progress(&mut self, delta: usize) -> f64 {
|
||||
self.written += delta;
|
||||
self.calc_progress_percentage()
|
||||
}
|
||||
|
||||
/// ### calc_progress
|
||||
///
|
||||
/// Calculate progress in a range between 0.0 to 1.0
|
||||
pub fn calc_progress(&self) -> f64 {
|
||||
let prog: f64 = (self.written as f64) / (self.total as f64);
|
||||
match prog > 1.0 {
|
||||
true => 1.0,
|
||||
false => prog,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### started
|
||||
///
|
||||
/// Get started
|
||||
pub fn started(&self) -> Instant {
|
||||
self.started
|
||||
}
|
||||
|
||||
/// ### calc_progress_percentage
|
||||
///
|
||||
/// Calculate the current transfer progress as percentage
|
||||
fn calc_progress_percentage(&self) -> f64 {
|
||||
self.calc_progress() * 100.0
|
||||
}
|
||||
|
||||
/// ### calc_bytes_per_second
|
||||
///
|
||||
/// Generic function to calculate bytes per second using elapsed time since transfer started and the bytes written
|
||||
/// and the total amount of bytes to write
|
||||
pub fn calc_bytes_per_second(&self) -> u64 {
|
||||
// bytes_written : elapsed_secs = x : 1
|
||||
let elapsed_secs: u64 = self.started.elapsed().as_secs();
|
||||
match elapsed_secs {
|
||||
0 => match self.written == self.total {
|
||||
// NOTE: would divide by 0 :D
|
||||
true => self.total as u64, // Download completed in less than 1 second
|
||||
false => 0, // 0 B/S
|
||||
},
|
||||
_ => self.written as u64 / elapsed_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### calc_eta
|
||||
///
|
||||
/// Calculate ETA for current transfer as seconds
|
||||
fn calc_eta(&self) -> u64 {
|
||||
let elapsed_secs: u64 = self.started.elapsed().as_secs();
|
||||
let prog: f64 = self.calc_progress_percentage();
|
||||
match prog as u64 {
|
||||
0 => 0,
|
||||
_ => ((elapsed_secs * 100) / (prog as u64)) - elapsed_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_ui_activities_filetransfer_lib_transfer_progress_states() {
|
||||
let mut states: ProgressStates = ProgressStates::default();
|
||||
assert_eq!(states.total, 0);
|
||||
assert_eq!(states.written, 0);
|
||||
assert!(states.started().elapsed().as_secs() < 5);
|
||||
// Init new transfer
|
||||
states.init(1024);
|
||||
assert_eq!(states.total, 1024);
|
||||
assert_eq!(states.written, 0);
|
||||
assert_eq!(states.calc_bytes_per_second(), 0);
|
||||
assert_eq!(states.calc_eta(), 0);
|
||||
assert_eq!(states.calc_progress_percentage(), 0.0);
|
||||
assert_eq!(states.calc_progress(), 0.0);
|
||||
assert_eq!(states.to_string().as_str(), "0.00% - ETA --:-- (0 B/s)");
|
||||
// Wait 4 second (virtually)
|
||||
states.started = states.started.checked_sub(Duration::from_secs(4)).unwrap();
|
||||
// Update state
|
||||
states.update_progress(256);
|
||||
assert_eq!(states.total, 1024);
|
||||
assert_eq!(states.written, 256);
|
||||
assert_eq!(states.calc_bytes_per_second(), 64); // 256 bytes in 4 seconds
|
||||
assert_eq!(states.calc_eta(), 12); // 16 total sub 4
|
||||
assert_eq!(states.calc_progress_percentage(), 25.0);
|
||||
assert_eq!(states.calc_progress(), 0.25);
|
||||
assert_eq!(states.to_string().as_str(), "25.00% - ETA 00:12 (64 B/s)");
|
||||
// 100%
|
||||
states.started = states.started.checked_sub(Duration::from_secs(12)).unwrap();
|
||||
states.update_progress(768);
|
||||
assert_eq!(states.total, 1024);
|
||||
assert_eq!(states.written, 1024);
|
||||
assert_eq!(states.calc_bytes_per_second(), 64); // 256 bytes in 4 seconds
|
||||
assert_eq!(states.calc_eta(), 0); // 16 total sub 4
|
||||
assert_eq!(states.calc_progress_percentage(), 100.0);
|
||||
assert_eq!(states.calc_progress(), 1.0);
|
||||
assert_eq!(states.to_string().as_str(), "100.00% - ETA --:-- (64 B/s)");
|
||||
// Check if terminated at started
|
||||
states.started = Instant::now();
|
||||
assert_eq!(states.calc_bytes_per_second(), 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_activities_filetransfer_lib_transfer_states() {
|
||||
let mut states: TransferStates = TransferStates::default();
|
||||
assert_eq!(states.aborted, false);
|
||||
assert_eq!(states.full.total, 0);
|
||||
assert_eq!(states.full.written, 0);
|
||||
assert!(states.full.started.elapsed().as_secs() < 5);
|
||||
assert_eq!(states.partial.total, 0);
|
||||
assert_eq!(states.partial.written, 0);
|
||||
assert!(states.partial.started.elapsed().as_secs() < 5);
|
||||
// Aborted
|
||||
states.abort();
|
||||
assert_eq!(states.aborted(), true);
|
||||
states.reset();
|
||||
assert_eq!(states.aborted(), false);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,18 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// This module is split into files, cause it's just too big
|
||||
mod actions;
|
||||
mod misc;
|
||||
mod session;
|
||||
mod update;
|
||||
mod view;
|
||||
pub(self) mod actions;
|
||||
pub(self) mod lib;
|
||||
pub(self) mod misc;
|
||||
pub(self) mod session;
|
||||
pub(self) mod update;
|
||||
pub(self) mod view;
|
||||
|
||||
// Dependencies
|
||||
extern crate chrono;
|
||||
extern crate crossterm;
|
||||
extern crate textwrap;
|
||||
extern crate tui;
|
||||
extern crate tuirealm;
|
||||
|
||||
// locals
|
||||
use super::{Activity, Context, ExitReason};
|
||||
@@ -46,20 +47,22 @@ use crate::filetransfer::sftp_transfer::SftpFileTransfer;
|
||||
use crate::filetransfer::{FileTransfer, FileTransferProtocol};
|
||||
use crate::fs::explorer::FileExplorer;
|
||||
use crate::fs::FsEntry;
|
||||
use crate::host::Localhost;
|
||||
use crate::system::config_client::ConfigClient;
|
||||
use crate::ui::layout::view::View;
|
||||
pub(crate) use lib::browser;
|
||||
use lib::browser::Browser;
|
||||
use lib::transfer::TransferStates;
|
||||
|
||||
// Includes
|
||||
use chrono::{DateTime, Local};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tuirealm::View;
|
||||
|
||||
// -- Storage keys
|
||||
|
||||
const STORAGE_EXPLORER_WIDTH: &str = "FILETRANSFER_EXPLORER_WIDTH";
|
||||
const STORAGE_LOGBOX_WIDTH: &str = "LOGBOX_WIDTH";
|
||||
|
||||
// -- components
|
||||
|
||||
@@ -67,7 +70,8 @@ const COMPONENT_EXPLORER_LOCAL: &str = "EXPLORER_LOCAL";
|
||||
const COMPONENT_EXPLORER_REMOTE: &str = "EXPLORER_REMOTE";
|
||||
const COMPONENT_EXPLORER_FIND: &str = "EXPLORER_FIND";
|
||||
const COMPONENT_LOG_BOX: &str = "LOG_BOX";
|
||||
const COMPONENT_PROGRESS_BAR: &str = "PROGRESS_BAR";
|
||||
const COMPONENT_PROGRESS_BAR_FULL: &str = "PROGRESS_BAR_FULL";
|
||||
const COMPONENT_PROGRESS_BAR_PARTIAL: &str = "PROGRESS_BAR_PARTIAL";
|
||||
const COMPONENT_TEXT_ERROR: &str = "TEXT_ERROR";
|
||||
const COMPONENT_TEXT_FATAL: &str = "TEXT_FATAL";
|
||||
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
|
||||
@@ -84,18 +88,9 @@ const COMPONENT_RADIO_DELETE: &str = "RADIO_DELETE";
|
||||
const COMPONENT_RADIO_DISCONNECT: &str = "RADIO_DISCONNECT";
|
||||
const COMPONENT_RADIO_QUIT: &str = "RADIO_QUIT";
|
||||
const COMPONENT_RADIO_SORTING: &str = "RADIO_SORTING";
|
||||
const COMPONENT_SPAN_STATUS_BAR: &str = "STATUS_BAR";
|
||||
const COMPONENT_LIST_FILEINFO: &str = "LIST_FILEINFO";
|
||||
|
||||
/// ## FileExplorerTab
|
||||
///
|
||||
/// File explorer tab
|
||||
enum FileExplorerTab {
|
||||
Local,
|
||||
Remote,
|
||||
FindLocal, // Find result tab
|
||||
FindRemote, // Find result tab
|
||||
}
|
||||
|
||||
/// ## LogLevel
|
||||
///
|
||||
/// Log level type
|
||||
@@ -118,90 +113,15 @@ impl LogRecord {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new LogRecord
|
||||
pub fn new(level: LogLevel, msg: &str) -> LogRecord {
|
||||
pub fn new(level: LogLevel, msg: String) -> LogRecord {
|
||||
LogRecord {
|
||||
time: Local::now(),
|
||||
level,
|
||||
msg: String::from(msg),
|
||||
msg,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### TransferStates
|
||||
///
|
||||
/// TransferStates contains the states related to the transfer process
|
||||
struct TransferStates {
|
||||
pub progress: f64, // Current read/write progress (percentage)
|
||||
pub started: Instant, // Instant the transfer process started
|
||||
pub aborted: bool, // Describes whether the transfer process has been aborted
|
||||
pub bytes_written: usize, // Bytes written during transfer
|
||||
pub bytes_total: usize, // Total bytes to write
|
||||
}
|
||||
|
||||
impl TransferStates {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new transfer states
|
||||
pub fn new() -> TransferStates {
|
||||
TransferStates {
|
||||
progress: 0.0,
|
||||
started: Instant::now(),
|
||||
aborted: false,
|
||||
bytes_written: 0,
|
||||
bytes_total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### reset
|
||||
///
|
||||
/// Re-intiialize transfer states
|
||||
pub fn reset(&mut self) {
|
||||
self.progress = 0.0;
|
||||
self.started = Instant::now();
|
||||
self.aborted = false;
|
||||
self.bytes_written = 0;
|
||||
self.bytes_total = 0;
|
||||
}
|
||||
|
||||
/// ### set_progress
|
||||
///
|
||||
/// Calculate progress percentage based on current progress
|
||||
pub fn set_progress(&mut self, w: usize, sz: usize) {
|
||||
self.bytes_written = w;
|
||||
self.bytes_total = sz;
|
||||
let mut prog: f64 = ((self.bytes_written as f64) * 100.0) / (self.bytes_total as f64);
|
||||
// Check value
|
||||
if prog > 100.0 {
|
||||
prog = 100.0;
|
||||
} else if prog < 0.0 {
|
||||
prog = 0.0;
|
||||
}
|
||||
self.progress = prog;
|
||||
}
|
||||
|
||||
/// ### byte_per_second
|
||||
///
|
||||
/// Calculate bytes per second
|
||||
pub fn bytes_per_second(&self) -> u64 {
|
||||
// bytes_written : elapsed_secs = x : 1
|
||||
let elapsed_secs: u64 = self.started.elapsed().as_secs();
|
||||
match elapsed_secs {
|
||||
0 => match self.bytes_written == self.bytes_total {
|
||||
// NOTE: would divide by 0 :D
|
||||
true => self.bytes_total as u64, // Download completed in less than 1 second
|
||||
false => 0, // 0 B/S
|
||||
},
|
||||
_ => self.bytes_written as u64 / elapsed_secs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TransferStates {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// ## FileTransferActivity
|
||||
///
|
||||
/// FileTransferActivity is the data holder for the file transfer activity
|
||||
@@ -209,13 +129,10 @@ pub struct FileTransferActivity {
|
||||
exit_reason: Option<ExitReason>, // Exit reason
|
||||
context: Option<Context>, // Context holder
|
||||
view: View, // View
|
||||
host: Localhost, // Localhost
|
||||
client: Box<dyn FileTransfer>, // File transfer client
|
||||
local: FileExplorer, // Local File explorer state
|
||||
remote: FileExplorer, // Remote File explorer state
|
||||
found: Option<FileExplorer>, // File explorer for find result
|
||||
tab: FileExplorerTab, // Current selected tab
|
||||
browser: Browser, // Browser
|
||||
log_records: VecDeque<LogRecord>, // Log records
|
||||
log_size: usize, // Log records size (max)
|
||||
transfer: TransferStates, // Transfer states
|
||||
}
|
||||
|
||||
@@ -223,13 +140,14 @@ impl FileTransferActivity {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new FileTransferActivity
|
||||
pub fn new(protocol: FileTransferProtocol) -> FileTransferActivity {
|
||||
pub fn new(host: Localhost, protocol: FileTransferProtocol) -> FileTransferActivity {
|
||||
// Get config client
|
||||
let config_client: Option<ConfigClient> = Self::init_config_client();
|
||||
FileTransferActivity {
|
||||
exit_reason: None,
|
||||
context: None,
|
||||
view: View::init(),
|
||||
host,
|
||||
client: match protocol {
|
||||
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new(
|
||||
Self::make_ssh_storage(config_client.as_ref()),
|
||||
@@ -239,15 +157,35 @@ impl FileTransferActivity {
|
||||
Self::make_ssh_storage(config_client.as_ref()),
|
||||
)),
|
||||
},
|
||||
local: Self::build_explorer(config_client.as_ref()),
|
||||
remote: Self::build_explorer(config_client.as_ref()),
|
||||
found: None,
|
||||
tab: FileExplorerTab::Local,
|
||||
browser: Browser::new(config_client.as_ref()),
|
||||
log_records: VecDeque::with_capacity(256), // 256 events is enough I guess
|
||||
log_size: 256, // Must match with capacity
|
||||
transfer: TransferStates::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local(&self) -> &FileExplorer {
|
||||
self.browser.local()
|
||||
}
|
||||
|
||||
pub(crate) fn local_mut(&mut self) -> &mut FileExplorer {
|
||||
self.browser.local_mut()
|
||||
}
|
||||
|
||||
pub(crate) fn remote(&self) -> &FileExplorer {
|
||||
self.browser.remote()
|
||||
}
|
||||
|
||||
pub(crate) fn remote_mut(&mut self) -> &mut FileExplorer {
|
||||
self.browser.remote_mut()
|
||||
}
|
||||
|
||||
pub(crate) fn found(&self) -> Option<&FileExplorer> {
|
||||
self.browser.found()
|
||||
}
|
||||
|
||||
pub(crate) fn found_mut(&mut self) -> Option<&mut FileExplorer> {
|
||||
self.browser.found_mut()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,25 +200,33 @@ impl Activity for FileTransferActivity {
|
||||
/// `on_create` is the function which must be called to initialize the activity.
|
||||
/// `on_create` must initialize all the data structures used by the activity
|
||||
fn on_create(&mut self, context: Context) {
|
||||
debug!("Initializing activity...");
|
||||
// Set context
|
||||
self.context = Some(context);
|
||||
// Clear terminal
|
||||
self.context.as_mut().unwrap().clear_screen();
|
||||
// Put raw mode on enabled
|
||||
let _ = enable_raw_mode();
|
||||
if let Err(err) = enable_raw_mode() {
|
||||
error!("Failed to enter raw mode: {}", err);
|
||||
}
|
||||
// Set working directory
|
||||
let pwd: PathBuf = self.context.as_ref().unwrap().local.pwd();
|
||||
let pwd: PathBuf = self.host.pwd();
|
||||
// Get files at current wd
|
||||
self.local_scan(pwd.as_path());
|
||||
self.local.wrkdir = pwd;
|
||||
self.local_mut().wrkdir = pwd;
|
||||
debug!("Read working directory");
|
||||
// Configure text editor
|
||||
self.setup_text_editor();
|
||||
debug!("Setup text editor");
|
||||
// init view
|
||||
self.init();
|
||||
debug!("Initialized view");
|
||||
// Verify error state from context
|
||||
if let Some(err) = self.context.as_mut().unwrap().get_error() {
|
||||
error!("Fatal error on create: {}", err);
|
||||
self.mount_fatal(&err);
|
||||
}
|
||||
info!("Created FileTransferActivity");
|
||||
}
|
||||
|
||||
/// ### on_draw
|
||||
@@ -297,6 +243,10 @@ impl Activity for FileTransferActivity {
|
||||
// Check if connected (popup must be None, otherwise would try reconnecting in loop in case of error)
|
||||
if !self.client.is_connected() && self.view.get_props(COMPONENT_TEXT_FATAL).is_none() {
|
||||
let params = self.context.as_ref().unwrap().ft_params.as_ref().unwrap();
|
||||
info!(
|
||||
"Client is not connected to remote; connecting to {}:{}",
|
||||
params.address, params.port
|
||||
);
|
||||
let msg: String = format!("Connecting to {}:{}...", params.address, params.port);
|
||||
// Set init state to connecting popup
|
||||
self.mount_wait(msg.as_str());
|
||||
@@ -330,7 +280,9 @@ impl Activity for FileTransferActivity {
|
||||
/// This function must be called once before terminating the activity.
|
||||
fn on_destroy(&mut self) -> Option<Context> {
|
||||
// Disable raw mode
|
||||
let _ = disable_raw_mode();
|
||||
if let Err(err) = disable_raw_mode() {
|
||||
error!("Failed to disable raw mode: {}", err);
|
||||
}
|
||||
// Disconnect client
|
||||
if self.client.is_connected() {
|
||||
let _ = self.client.disconnect();
|
||||
@@ -29,25 +29,27 @@
|
||||
extern crate bytesize;
|
||||
// locals
|
||||
use super::{
|
||||
FileExplorerTab, FileTransferActivity, LogLevel, COMPONENT_EXPLORER_FIND,
|
||||
COMPONENT_EXPLORER_LOCAL, COMPONENT_EXPLORER_REMOTE, COMPONENT_INPUT_COPY,
|
||||
COMPONENT_INPUT_EXEC, COMPONENT_INPUT_FIND, COMPONENT_INPUT_GOTO, COMPONENT_INPUT_MKDIR,
|
||||
COMPONENT_INPUT_NEWFILE, COMPONENT_INPUT_RENAME, COMPONENT_INPUT_SAVEAS,
|
||||
COMPONENT_LIST_FILEINFO, COMPONENT_LOG_BOX, COMPONENT_PROGRESS_BAR, COMPONENT_RADIO_DELETE,
|
||||
COMPONENT_RADIO_DISCONNECT, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SORTING,
|
||||
COMPONENT_TEXT_ERROR, COMPONENT_TEXT_FATAL, COMPONENT_TEXT_HELP,
|
||||
actions::SelectedEntry, browser::FileExplorerTab, FileTransferActivity, LogLevel,
|
||||
COMPONENT_EXPLORER_FIND, COMPONENT_EXPLORER_LOCAL, COMPONENT_EXPLORER_REMOTE,
|
||||
COMPONENT_INPUT_COPY, COMPONENT_INPUT_EXEC, COMPONENT_INPUT_FIND, COMPONENT_INPUT_GOTO,
|
||||
COMPONENT_INPUT_MKDIR, COMPONENT_INPUT_NEWFILE, COMPONENT_INPUT_RENAME, COMPONENT_INPUT_SAVEAS,
|
||||
COMPONENT_LIST_FILEINFO, COMPONENT_LOG_BOX, COMPONENT_PROGRESS_BAR_FULL,
|
||||
COMPONENT_PROGRESS_BAR_PARTIAL, COMPONENT_RADIO_DELETE, COMPONENT_RADIO_DISCONNECT,
|
||||
COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SORTING, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_FATAL,
|
||||
COMPONENT_TEXT_HELP,
|
||||
};
|
||||
use crate::fs::explorer::FileSorting;
|
||||
use crate::fs::FsEntry;
|
||||
use crate::ui::activities::keymap::*;
|
||||
use crate::ui::layout::props::{
|
||||
PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
|
||||
};
|
||||
use crate::ui::layout::{Msg, Payload};
|
||||
use crate::ui::components::{file_list::FileListPropsBuilder, logbox::LogboxPropsBuilder};
|
||||
use crate::ui::keymap::*;
|
||||
// externals
|
||||
use bytesize::ByteSize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tui::style::Color;
|
||||
use tuirealm::{
|
||||
components::progress_bar::ProgressBarPropsBuilder,
|
||||
props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder},
|
||||
tui::style::Color,
|
||||
Msg, Payload, Value,
|
||||
};
|
||||
|
||||
impl FileTransferActivity {
|
||||
// -- update
|
||||
@@ -66,229 +68,144 @@ impl FileTransferActivity {
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_RIGHT) => {
|
||||
// Change tab
|
||||
self.view.active(COMPONENT_EXPLORER_REMOTE);
|
||||
self.tab = FileExplorerTab::Remote;
|
||||
self.browser.change_tab(FileExplorerTab::Remote);
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_BACKSPACE) => {
|
||||
// Go to previous directory
|
||||
if let Some(d) = self.local.popd() {
|
||||
self.local_changedir(d.as_path(), false);
|
||||
self.action_go_to_previous_local_dir(false);
|
||||
if self.browser.sync_browsing {
|
||||
let _ = self.update_remote_filelist();
|
||||
}
|
||||
// Reload file list component
|
||||
self.update_local_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
(COMPONENT_EXPLORER_LOCAL, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
|
||||
// Match selected file
|
||||
let mut entry: Option<FsEntry> = None;
|
||||
if let Some(e) = self.local.get(*idx) {
|
||||
if let Some(e) = self.local().get(*idx) {
|
||||
entry = Some(e.clone());
|
||||
}
|
||||
if let Some(entry) = entry {
|
||||
// If directory, enter directory, otherwise check if symlink
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.local_changedir(dir.abs_path.as_path(), true);
|
||||
self.update_local_filelist()
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
// Check if symlink
|
||||
match &file.symlink {
|
||||
Some(pointer) => match &**pointer {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.local_changedir(dir.abs_path.as_path(), true);
|
||||
self.update_local_filelist()
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
if self.action_enter_local_dir(entry, false) {
|
||||
// Update file list if sync
|
||||
if self.browser.sync_browsing {
|
||||
let _ = self.update_remote_filelist();
|
||||
}
|
||||
self.update_local_filelist()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_SPACE) => {
|
||||
// Get pwd
|
||||
let wrkdir: PathBuf = self.remote.wrkdir.clone();
|
||||
// Get file and clone (due to mutable / immutable stuff...)
|
||||
if self.get_local_file_entry().is_some() {
|
||||
let file: FsEntry = self.get_local_file_entry().unwrap().clone();
|
||||
let name: String = file.get_name().to_string();
|
||||
// Call upload; pass realfile, keep link name
|
||||
self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(name));
|
||||
self.update_remote_filelist()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
self.action_local_send();
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_A) => {
|
||||
// Toggle hidden files
|
||||
self.local.toggle_hidden_files();
|
||||
self.local_mut().toggle_hidden_files();
|
||||
// Reload file list component
|
||||
self.update_local_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_I) => {
|
||||
let file: Option<FsEntry> = self.get_local_file_entry().cloned();
|
||||
if let Some(file) = file {
|
||||
if let SelectedEntry::One(file) = self.get_local_selected_entries() {
|
||||
self.mount_file_info(&file);
|
||||
}
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_L) => {
|
||||
// Reload directory
|
||||
let pwd: PathBuf = self.local.wrkdir.clone();
|
||||
let pwd: PathBuf = self.local().wrkdir.clone();
|
||||
self.local_scan(pwd.as_path());
|
||||
// Reload file list component
|
||||
self.update_local_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_O) => {
|
||||
// Clone entry due to mutable stuff...
|
||||
if self.get_local_file_entry().is_some() {
|
||||
let fsentry: FsEntry = self.get_local_file_entry().unwrap().clone();
|
||||
// Check if file
|
||||
if fsentry.is_file() {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Opening file \"{}\"...", fsentry.get_abs_path().display())
|
||||
.as_str(),
|
||||
);
|
||||
// Edit file
|
||||
match self.edit_local_file(fsentry.get_abs_path().as_path()) {
|
||||
Ok(_) => {
|
||||
// Reload directory
|
||||
let pwd: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(pwd.as_path());
|
||||
}
|
||||
Err(err) => self.log_and_alert(LogLevel::Error, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
self.action_edit_local_file();
|
||||
// Reload file list component
|
||||
self.update_local_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_U) => {
|
||||
// Get pwd
|
||||
let path: PathBuf = self.local.wrkdir.clone();
|
||||
// Go to parent directory
|
||||
if let Some(parent) = path.as_path().parent() {
|
||||
self.local_changedir(parent, true);
|
||||
// Reload file list component
|
||||
self.action_go_to_local_upper_dir(false);
|
||||
if self.browser.sync_browsing {
|
||||
let _ = self.update_remote_filelist();
|
||||
}
|
||||
// Reload file list component
|
||||
self.update_local_filelist()
|
||||
}
|
||||
// -- remote tab
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_LEFT) => {
|
||||
// Change tab
|
||||
self.view.active(COMPONENT_EXPLORER_LOCAL);
|
||||
self.tab = FileExplorerTab::Local;
|
||||
self.browser.change_tab(FileExplorerTab::Local);
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
(COMPONENT_EXPLORER_REMOTE, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
|
||||
// Match selected file
|
||||
let mut entry: Option<FsEntry> = None;
|
||||
if let Some(e) = self.remote.get(*idx) {
|
||||
if let Some(e) = self.remote().get(*idx) {
|
||||
entry = Some(e.clone());
|
||||
}
|
||||
if let Some(entry) = entry {
|
||||
// If directory, enter directory; if file, check if is symlink
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.remote_changedir(dir.abs_path.as_path(), true);
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
match &file.symlink {
|
||||
Some(symlink_entry) => {
|
||||
// If symlink and is directory, point to symlink
|
||||
match &**symlink_entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.remote_changedir(dir.abs_path.as_path(), true);
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
if self.action_enter_remote_dir(entry, false) {
|
||||
// Update file list if sync
|
||||
if self.browser.sync_browsing {
|
||||
let _ = self.update_local_filelist();
|
||||
}
|
||||
self.update_remote_filelist()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_SPACE) => {
|
||||
// Get file and clone (due to mutable / immutable stuff...)
|
||||
if self.get_remote_file_entry().is_some() {
|
||||
let file: FsEntry = self.get_remote_file_entry().unwrap().clone();
|
||||
let name: String = file.get_name().to_string();
|
||||
// Call upload; pass realfile, keep link name
|
||||
let wrkdir: PathBuf = self.local.wrkdir.clone();
|
||||
self.filetransfer_recv(&file.get_realfile(), wrkdir.as_path(), Some(name));
|
||||
self.update_local_filelist()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
self.action_remote_recv();
|
||||
self.update_local_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_BACKSPACE) => {
|
||||
// Go to previous directory
|
||||
if let Some(d) = self.remote.popd() {
|
||||
self.remote_changedir(d.as_path(), false);
|
||||
self.action_go_to_previous_remote_dir(false);
|
||||
// If sync is enabled update local too
|
||||
if self.browser.sync_browsing {
|
||||
let _ = self.update_local_filelist();
|
||||
}
|
||||
// Reload file list component
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_A) => {
|
||||
// Toggle hidden files
|
||||
self.remote.toggle_hidden_files();
|
||||
self.remote_mut().toggle_hidden_files();
|
||||
// Reload file list component
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_I) => {
|
||||
let file: Option<FsEntry> = self.get_remote_file_entry().cloned();
|
||||
if let Some(file) = file {
|
||||
if let SelectedEntry::One(file) = self.get_remote_selected_entries() {
|
||||
self.mount_file_info(&file);
|
||||
}
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_L) => {
|
||||
// Reload directory
|
||||
let pwd: PathBuf = self.remote.wrkdir.clone();
|
||||
let pwd: PathBuf = self.remote().wrkdir.clone();
|
||||
self.remote_scan(pwd.as_path());
|
||||
// Reload file list component
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_O) => {
|
||||
// Clone entry due to mutable stuff...
|
||||
if self.get_remote_file_entry().is_some() {
|
||||
let fsentry: FsEntry = self.get_remote_file_entry().unwrap().clone();
|
||||
// Check if file
|
||||
if let FsEntry::File(file) = fsentry.clone() {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Opening file \"{}\"...", fsentry.get_abs_path().display())
|
||||
.as_str(),
|
||||
);
|
||||
// Edit file
|
||||
match self.edit_remote_file(&file) {
|
||||
Ok(_) => {
|
||||
// Reload directory
|
||||
let pwd: PathBuf = self.remote.wrkdir.clone();
|
||||
self.remote_scan(pwd.as_path());
|
||||
}
|
||||
Err(err) => self.log_and_alert(LogLevel::Error, err),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Edit file
|
||||
self.action_edit_remote_file();
|
||||
// Reload file list component
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_U) => {
|
||||
// Get pwd
|
||||
let path: PathBuf = self.remote.wrkdir.clone();
|
||||
// Go to parent directory
|
||||
if let Some(parent) = path.as_path().parent() {
|
||||
self.remote_changedir(parent, true);
|
||||
self.action_go_to_remote_upper_dir(false);
|
||||
if self.browser.sync_browsing {
|
||||
let _ = self.update_local_filelist();
|
||||
}
|
||||
// Reload file list component
|
||||
self.update_remote_filelist()
|
||||
@@ -355,6 +272,14 @@ impl FileTransferActivity {
|
||||
self.mount_exec();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_Y)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_Y) => {
|
||||
// Toggle browser sync
|
||||
self.browser.toggle_sync_browsing();
|
||||
// Update status bar
|
||||
self.refresh_status_bar();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_ESC)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_ESC)
|
||||
| (COMPONENT_LOG_BOX, &MSG_KEY_ESC) => {
|
||||
@@ -378,15 +303,15 @@ impl FileTransferActivity {
|
||||
self.finalize_find();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_FIND, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
(COMPONENT_EXPLORER_FIND, Msg::OnSubmit(_)) => {
|
||||
// Find changedir
|
||||
self.action_find_changedir(*idx);
|
||||
self.action_find_changedir();
|
||||
// Umount find
|
||||
self.umount_find();
|
||||
// Finalize find
|
||||
self.finalize_find();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -394,17 +319,12 @@ impl FileTransferActivity {
|
||||
}
|
||||
(COMPONENT_EXPLORER_FIND, &MSG_KEY_SPACE) => {
|
||||
// Get entry
|
||||
match self.view.get_value(COMPONENT_EXPLORER_FIND) {
|
||||
Some(Payload::Unsigned(idx)) => {
|
||||
self.action_find_transfer(idx, None);
|
||||
// Reload files
|
||||
match self.tab {
|
||||
// NOTE: swapped by purpose
|
||||
FileExplorerTab::FindLocal => self.update_remote_filelist(),
|
||||
FileExplorerTab::FindRemote => self.update_local_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
self.action_find_transfer(None);
|
||||
// Reload files
|
||||
match self.browser.tab() {
|
||||
// NOTE: swapped by purpose
|
||||
FileExplorerTab::FindLocal => self.update_remote_filelist(),
|
||||
FileExplorerTab::FindRemote => self.update_local_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -424,16 +344,16 @@ impl FileTransferActivity {
|
||||
self.umount_copy();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_COPY, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
(COMPONENT_INPUT_COPY, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
// Copy file
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_copy(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_remote_copy(input.to_string()),
|
||||
_ => panic!("Found tab doesn't support COPY"),
|
||||
}
|
||||
self.umount_copy();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -444,16 +364,16 @@ impl FileTransferActivity {
|
||||
self.umount_exec();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_EXEC, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
(COMPONENT_INPUT_EXEC, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
// Exex command
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_exec(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_remote_exec(input.to_string()),
|
||||
_ => panic!("Found tab doesn't support EXEC"),
|
||||
}
|
||||
self.umount_exec();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -464,10 +384,10 @@ impl FileTransferActivity {
|
||||
self.umount_find_input();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_FIND, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
(COMPONENT_INPUT_FIND, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
self.umount_find_input();
|
||||
// Find
|
||||
let res: Result<Vec<FsEntry>, String> = match self.tab {
|
||||
let res: Result<Vec<FsEntry>, String> = match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_find(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_remote_find(input.to_string()),
|
||||
_ => panic!("Trying to search for files, while already in a find result"),
|
||||
@@ -480,18 +400,16 @@ impl FileTransferActivity {
|
||||
}
|
||||
Ok(files) => {
|
||||
// Create explorer and load files
|
||||
let mut explorer = Self::build_found_explorer();
|
||||
explorer.set_files(files);
|
||||
self.found = Some(explorer);
|
||||
self.browser.set_found(files);
|
||||
// Mount result widget
|
||||
self.mount_find(input);
|
||||
self.update_find_list();
|
||||
// Initialize tab
|
||||
self.tab = match self.tab {
|
||||
self.browser.change_tab(match self.browser.tab() {
|
||||
FileExplorerTab::Local => FileExplorerTab::FindLocal,
|
||||
FileExplorerTab::Remote => FileExplorerTab::FindRemote,
|
||||
_ => FileExplorerTab::FindLocal,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -501,16 +419,28 @@ impl FileTransferActivity {
|
||||
self.umount_goto();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_GOTO, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => self.action_change_local_dir(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_change_remote_dir(input.to_string()),
|
||||
(COMPONENT_INPUT_GOTO, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => {
|
||||
self.action_change_local_dir(input.to_string(), false)
|
||||
}
|
||||
FileExplorerTab::Remote => {
|
||||
self.action_change_remote_dir(input.to_string(), false)
|
||||
}
|
||||
_ => panic!("Found tab doesn't support GOTO"),
|
||||
}
|
||||
// Umount
|
||||
self.umount_goto();
|
||||
// Reload files if sync
|
||||
if self.browser.sync_browsing {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Remote => self.update_local_filelist(),
|
||||
FileExplorerTab::Local => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -521,15 +451,15 @@ impl FileTransferActivity {
|
||||
self.umount_mkdir();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_MKDIR, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.tab {
|
||||
(COMPONENT_INPUT_MKDIR, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_mkdir(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_remote_mkdir(input.to_string()),
|
||||
_ => panic!("Found tab doesn't support MKDIR"),
|
||||
}
|
||||
self.umount_mkdir();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -540,15 +470,15 @@ impl FileTransferActivity {
|
||||
self.umount_newfile();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_NEWFILE, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.tab {
|
||||
(COMPONENT_INPUT_NEWFILE, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_newfile(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_remote_newfile(input.to_string()),
|
||||
_ => panic!("Found tab doesn't support NEWFILE"),
|
||||
}
|
||||
self.umount_newfile();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -559,15 +489,15 @@ impl FileTransferActivity {
|
||||
self.umount_rename();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_RENAME, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.tab {
|
||||
(COMPONENT_INPUT_RENAME, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_rename(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_remote_rename(input.to_string()),
|
||||
_ => panic!("Found tab doesn't support RENAME"),
|
||||
}
|
||||
self.umount_rename();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -578,22 +508,18 @@ impl FileTransferActivity {
|
||||
self.umount_saveas();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_SAVEAS, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.tab {
|
||||
(COMPONENT_INPUT_SAVEAS, Msg::OnSubmit(Payload::One(Value::Str(input)))) => {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_saveas(input.to_string()),
|
||||
FileExplorerTab::Remote => self.action_remote_saveas(input.to_string()),
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => {
|
||||
// Get entry
|
||||
if let Some(Payload::Unsigned(idx)) =
|
||||
self.view.get_value(COMPONENT_EXPLORER_FIND)
|
||||
{
|
||||
self.action_find_transfer(idx, Some(input.to_string()));
|
||||
}
|
||||
self.action_find_transfer(Some(input.to_string()));
|
||||
}
|
||||
}
|
||||
self.umount_saveas();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
// NOTE: Swapped is intentional
|
||||
FileExplorerTab::Local => self.update_remote_filelist(),
|
||||
FileExplorerTab::Remote => self.update_local_filelist(),
|
||||
@@ -609,30 +535,41 @@ impl FileTransferActivity {
|
||||
}
|
||||
// -- delete
|
||||
(COMPONENT_RADIO_DELETE, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
| (COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
|
||||
self.umount_radio_delete();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
(COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
|
||||
// Choice is 'YES'
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.action_local_delete(),
|
||||
FileExplorerTab::Remote => self.action_remote_delete(),
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => {
|
||||
// Get entry
|
||||
if let Some(Payload::Unsigned(idx)) =
|
||||
self.view.get_value(COMPONENT_EXPLORER_FIND)
|
||||
{
|
||||
self.action_find_delete(idx);
|
||||
// Reload entries
|
||||
self.found.as_mut().unwrap().del_entry(idx);
|
||||
self.update_find_list();
|
||||
self.action_find_delete();
|
||||
// Delete entries
|
||||
match self.view.get_state(COMPONENT_EXPLORER_FIND) {
|
||||
Some(Payload::One(Value::Usize(idx))) => {
|
||||
// Reload entries
|
||||
self.found_mut().unwrap().del_entry(idx);
|
||||
}
|
||||
Some(Payload::Vec(values)) => {
|
||||
values
|
||||
.iter()
|
||||
.map(|x| match x {
|
||||
Value::Usize(v) => *v,
|
||||
_ => 0,
|
||||
})
|
||||
.for_each(|x| self.found_mut().unwrap().del_entry(x));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.update_find_list();
|
||||
}
|
||||
}
|
||||
self.umount_radio_delete();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
FileExplorerTab::FindLocal => self.update_local_filelist(),
|
||||
@@ -641,32 +578,33 @@ impl FileTransferActivity {
|
||||
}
|
||||
// -- disconnect
|
||||
(COMPONENT_RADIO_DISCONNECT, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
| (COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
|
||||
self.umount_disconnect();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
(COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
|
||||
self.disconnect();
|
||||
self.umount_disconnect();
|
||||
None
|
||||
}
|
||||
// -- quit
|
||||
(COMPONENT_RADIO_QUIT, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
| (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
|
||||
self.disconnect_and_quit();
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
// -- sorting
|
||||
(COMPONENT_RADIO_SORTING, &MSG_KEY_ESC) => {
|
||||
(COMPONENT_RADIO_SORTING, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_SORTING, Msg::OnSubmit(_)) => {
|
||||
self.umount_file_sorting();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_SORTING, Msg::OnSubmit(Payload::Unsigned(mode))) => {
|
||||
(COMPONENT_RADIO_SORTING, Msg::OnChange(Payload::One(Value::Usize(mode)))) => {
|
||||
// Get sorting mode
|
||||
let sorting: FileSorting = match mode {
|
||||
1 => FileSorting::ByModifyTime,
|
||||
@@ -674,14 +612,15 @@ impl FileTransferActivity {
|
||||
3 => FileSorting::BySize,
|
||||
_ => FileSorting::ByName,
|
||||
};
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => self.local.sort_by(sorting),
|
||||
FileExplorerTab::Remote => self.remote.sort_by(sorting),
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.local_mut().sort_by(sorting),
|
||||
FileExplorerTab::Remote => self.remote_mut().sort_by(sorting),
|
||||
_ => panic!("Found result doesn't support SORTING"),
|
||||
}
|
||||
self.umount_file_sorting();
|
||||
// Update status bar
|
||||
self.refresh_status_bar();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
@@ -703,9 +642,9 @@ impl FileTransferActivity {
|
||||
None
|
||||
}
|
||||
// -- progress bar
|
||||
(COMPONENT_PROGRESS_BAR, &MSG_KEY_CTRL_C) => {
|
||||
(COMPONENT_PROGRESS_BAR_PARTIAL, &MSG_KEY_CTRL_C) => {
|
||||
// Set transfer aborted to True
|
||||
self.transfer.aborted = true;
|
||||
self.transfer.abort();
|
||||
None
|
||||
}
|
||||
// -- fallback
|
||||
@@ -718,11 +657,7 @@ impl FileTransferActivity {
|
||||
///
|
||||
/// Update local file list
|
||||
pub(super) fn update_local_filelist(&mut self) -> Option<(String, Msg)> {
|
||||
match self
|
||||
.view
|
||||
.get_props(super::COMPONENT_EXPLORER_LOCAL)
|
||||
.as_mut()
|
||||
{
|
||||
match self.view.get_props(super::COMPONENT_EXPLORER_LOCAL) {
|
||||
Some(props) => {
|
||||
// Get width
|
||||
let width: usize = self
|
||||
@@ -744,20 +679,20 @@ impl FileTransferActivity {
|
||||
"{}:{} ",
|
||||
hostname,
|
||||
FileTransferActivity::elide_wrkdir_path(
|
||||
self.local.wrkdir.as_path(),
|
||||
self.local().wrkdir.as_path(),
|
||||
hostname.as_str(),
|
||||
width
|
||||
)
|
||||
.display()
|
||||
);
|
||||
let files: Vec<TextSpan> = self
|
||||
.local
|
||||
let files: Vec<String> = self
|
||||
.local()
|
||||
.iter_files()
|
||||
.map(|x: &FsEntry| TextSpan::from(self.local.fmt_file(x)))
|
||||
.map(|x: &FsEntry| self.local().fmt_file(x))
|
||||
.collect();
|
||||
// Update
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(hostname), Some(files)))
|
||||
let props = FileListPropsBuilder::from(props)
|
||||
.with_files(Some(hostname), files)
|
||||
.build();
|
||||
// Update
|
||||
self.view.update(super::COMPONENT_EXPLORER_LOCAL, props)
|
||||
@@ -770,11 +705,7 @@ impl FileTransferActivity {
|
||||
///
|
||||
/// Update remote file list
|
||||
pub(super) fn update_remote_filelist(&mut self) -> Option<(String, Msg)> {
|
||||
match self
|
||||
.view
|
||||
.get_props(super::COMPONENT_EXPLORER_REMOTE)
|
||||
.as_mut()
|
||||
{
|
||||
match self.view.get_props(super::COMPONENT_EXPLORER_REMOTE) {
|
||||
Some(props) => {
|
||||
// Get width
|
||||
let width: usize = self
|
||||
@@ -789,20 +720,20 @@ impl FileTransferActivity {
|
||||
"{}:{} ",
|
||||
params.address,
|
||||
FileTransferActivity::elide_wrkdir_path(
|
||||
self.remote.wrkdir.as_path(),
|
||||
self.remote().wrkdir.as_path(),
|
||||
params.address.as_str(),
|
||||
width
|
||||
)
|
||||
.display()
|
||||
);
|
||||
let files: Vec<TextSpan> = self
|
||||
.remote
|
||||
let files: Vec<String> = self
|
||||
.remote()
|
||||
.iter_files()
|
||||
.map(|x: &FsEntry| TextSpan::from(self.remote.fmt_file(x)))
|
||||
.map(|x: &FsEntry| self.remote().fmt_file(x))
|
||||
.collect();
|
||||
// Update
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(hostname), Some(files)))
|
||||
let props = FileListPropsBuilder::from(props)
|
||||
.with_files(Some(hostname), files)
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_EXPLORER_REMOTE, props)
|
||||
}
|
||||
@@ -814,21 +745,11 @@ impl FileTransferActivity {
|
||||
///
|
||||
/// Update log box
|
||||
pub(super) fn update_logbox(&mut self) -> Option<(String, Msg)> {
|
||||
match self.view.get_props(super::COMPONENT_LOG_BOX).as_mut() {
|
||||
match self.view.get_props(super::COMPONENT_LOG_BOX) {
|
||||
Some(props) => {
|
||||
// Get width
|
||||
let width: usize = self
|
||||
.context
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store
|
||||
.get_unsigned(super::STORAGE_LOGBOX_WIDTH)
|
||||
.unwrap_or(256);
|
||||
// Make log entries
|
||||
let mut table: TableBuilder = TableBuilder::default();
|
||||
for (idx, record) in self.log_records.iter().enumerate() {
|
||||
// Split rows by width NOTE: -37 'cause log prefix -3 cause of log line cursor
|
||||
let record_rows = textwrap::wrap(record.msg.as_str(), (width as usize) - 40);
|
||||
// Add row if not first row
|
||||
if idx > 0 {
|
||||
table.add_row();
|
||||
@@ -838,46 +759,33 @@ impl FileTransferActivity {
|
||||
LogLevel::Warn => Color::Yellow,
|
||||
LogLevel::Info => Color::Green,
|
||||
};
|
||||
for (idx, row) in record_rows.iter().enumerate() {
|
||||
match idx {
|
||||
0 => {
|
||||
// First row
|
||||
table
|
||||
.add_col(TextSpan::from(format!(
|
||||
"{}",
|
||||
record.time.format("%Y-%m-%dT%H:%M:%S%Z")
|
||||
)))
|
||||
.add_col(TextSpan::from(" ["))
|
||||
.add_col(
|
||||
TextSpanBuilder::new(
|
||||
format!(
|
||||
"{:5}",
|
||||
match record.level {
|
||||
LogLevel::Error => "ERROR",
|
||||
LogLevel::Warn => "WARN",
|
||||
LogLevel::Info => "INFO",
|
||||
}
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.with_foreground(fg)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from("]: "))
|
||||
.add_col(TextSpan::from(row.as_ref()));
|
||||
}
|
||||
_ => {
|
||||
table.add_col(TextSpan::from(textwrap::indent(
|
||||
row.as_ref(),
|
||||
" ",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
table
|
||||
.add_col(TextSpan::from(format!(
|
||||
"{}",
|
||||
record.time.format("%Y-%m-%dT%H:%M:%S%Z")
|
||||
)))
|
||||
.add_col(TextSpan::from(" ["))
|
||||
.add_col(
|
||||
TextSpanBuilder::new(
|
||||
format!(
|
||||
"{:5}",
|
||||
match record.level {
|
||||
LogLevel::Error => "ERROR",
|
||||
LogLevel::Warn => "WARN",
|
||||
LogLevel::Info => "INFO",
|
||||
}
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
.with_foreground(fg)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from("]: "))
|
||||
.add_col(TextSpan::from(record.msg.as_ref()));
|
||||
}
|
||||
let table = table.build();
|
||||
let props = props
|
||||
.with_texts(TextParts::table(Some(String::from("Log")), table))
|
||||
let props = LogboxPropsBuilder::from(props)
|
||||
.with_log(Some(String::from("Log")), table)
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_LOG_BOX, props)
|
||||
}
|
||||
@@ -885,34 +793,22 @@ impl FileTransferActivity {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_progress_bar(&mut self, text: String) -> Option<(String, Msg)> {
|
||||
match self.view.get_props(COMPONENT_PROGRESS_BAR).as_mut() {
|
||||
pub(super) fn update_progress_bar(&mut self, filename: String) -> Option<(String, Msg)> {
|
||||
if let Some(props) = self.view.get_props(COMPONENT_PROGRESS_BAR_FULL) {
|
||||
let root_name: String = props.texts.title.as_deref().unwrap_or("").to_string();
|
||||
let props = ProgressBarPropsBuilder::from(props)
|
||||
.with_texts(Some(root_name), self.transfer.full.to_string())
|
||||
.with_progress(self.transfer.full.calc_progress())
|
||||
.build();
|
||||
let _ = self.view.update(COMPONENT_PROGRESS_BAR_FULL, props);
|
||||
}
|
||||
match self.view.get_props(COMPONENT_PROGRESS_BAR_PARTIAL) {
|
||||
Some(props) => {
|
||||
// Calculate ETA
|
||||
let elapsed_secs: u64 = self.transfer.started.elapsed().as_secs();
|
||||
let eta: String = match self.transfer.progress as u64 {
|
||||
0 => String::from("--:--"), // NOTE: would divide by 0 :D
|
||||
_ => {
|
||||
let eta: u64 =
|
||||
((elapsed_secs * 100) / (self.transfer.progress as u64)) - elapsed_secs;
|
||||
format!("{:0width$}:{:0width$}", (eta / 60), (eta % 60), width = 2)
|
||||
}
|
||||
};
|
||||
// Calculate bytes/s
|
||||
let label = format!(
|
||||
"{:.2}% - ETA {} ({}/s)",
|
||||
self.transfer.progress,
|
||||
eta,
|
||||
ByteSize(self.transfer.bytes_per_second())
|
||||
);
|
||||
let props = props
|
||||
.with_texts(TextParts::new(
|
||||
Some(text),
|
||||
Some(vec![TextSpan::from(label)]),
|
||||
))
|
||||
.with_value(PropValue::Float(self.transfer.progress / 100.0))
|
||||
let props = ProgressBarPropsBuilder::from(props)
|
||||
.with_texts(Some(filename), self.transfer.partial.to_string())
|
||||
.with_progress(self.transfer.partial.calc_progress())
|
||||
.build();
|
||||
self.view.update(COMPONENT_PROGRESS_BAR, props)
|
||||
self.view.update(COMPONENT_PROGRESS_BAR_PARTIAL, props)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
@@ -923,32 +819,29 @@ impl FileTransferActivity {
|
||||
/// Finalize find process
|
||||
fn finalize_find(&mut self) {
|
||||
// Set found to none
|
||||
self.found = None;
|
||||
self.browser.del_found();
|
||||
// Restore tab
|
||||
self.tab = match self.tab {
|
||||
self.browser.change_tab(match self.browser.tab() {
|
||||
FileExplorerTab::FindLocal => FileExplorerTab::Local,
|
||||
FileExplorerTab::FindRemote => FileExplorerTab::Remote,
|
||||
_ => FileExplorerTab::Local,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
fn update_find_list(&mut self) -> Option<(String, Msg)> {
|
||||
match self.view.get_props(COMPONENT_EXPLORER_FIND).as_mut() {
|
||||
match self.view.get_props(COMPONENT_EXPLORER_FIND) {
|
||||
None => None,
|
||||
Some(props) => {
|
||||
let props = props.build();
|
||||
let title: String = props.texts.title.clone().unwrap_or_default();
|
||||
let mut props = PropsBuilder::from(props);
|
||||
// Prepare files
|
||||
let file_texts: Vec<TextSpan> = self
|
||||
.found
|
||||
.as_ref()
|
||||
let files: Vec<String> = self
|
||||
.found()
|
||||
.unwrap()
|
||||
.iter_files()
|
||||
.map(|x: &FsEntry| TextSpan::from(self.found.as_ref().unwrap().fmt_file(x)))
|
||||
.map(|x: &FsEntry| self.found().unwrap().fmt_file(x))
|
||||
.collect();
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(title), Some(file_texts)))
|
||||
let props = FileListPropsBuilder::from(props)
|
||||
.with_files(Some(title), files)
|
||||
.build();
|
||||
self.view.update(COMPONENT_EXPLORER_FIND, props)
|
||||
}
|
||||
@@ -31,26 +31,32 @@ extern crate hostname;
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
extern crate users;
|
||||
// locals
|
||||
use super::{Context, FileExplorerTab, FileTransferActivity};
|
||||
use super::{browser::FileExplorerTab, Context, FileTransferActivity};
|
||||
use crate::fs::explorer::FileSorting;
|
||||
use crate::fs::FsEntry;
|
||||
use crate::ui::layout::components::{
|
||||
file_list::FileList, input::Input, logbox::LogBox, msgbox::MsgBox, progress_bar::ProgressBar,
|
||||
radio_group::RadioGroup, table::Table,
|
||||
use crate::ui::components::{
|
||||
file_list::{FileList, FileListPropsBuilder},
|
||||
logbox::{LogBox, LogboxPropsBuilder},
|
||||
msgbox::{MsgBox, MsgBoxPropsBuilder},
|
||||
};
|
||||
use crate::ui::layout::props::{
|
||||
PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
|
||||
};
|
||||
use crate::ui::layout::utils::draw_area_in;
|
||||
use crate::ui::store::Store;
|
||||
use crate::utils::fmt::fmt_time;
|
||||
use crate::utils::ui::draw_area_in;
|
||||
// Ext
|
||||
use bytesize::ByteSize;
|
||||
use std::path::PathBuf;
|
||||
use tui::{
|
||||
use tuirealm::components::{
|
||||
input::{Input, InputPropsBuilder},
|
||||
progress_bar::{ProgressBar, ProgressBarPropsBuilder},
|
||||
radio::{Radio, RadioPropsBuilder},
|
||||
span::{Span, SpanPropsBuilder},
|
||||
table::{Table, TablePropsBuilder},
|
||||
};
|
||||
use tuirealm::props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder};
|
||||
use tuirealm::tui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
widgets::Clear,
|
||||
widgets::{BorderType, Borders, Clear},
|
||||
};
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
use users::{get_group_by_gid, get_user_by_uid};
|
||||
@@ -66,9 +72,10 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_EXPLORER_LOCAL,
|
||||
Box::new(FileList::new(
|
||||
PropsBuilder::default()
|
||||
FileListPropsBuilder::default()
|
||||
.with_background(Color::Yellow)
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::Yellow)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -76,9 +83,10 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_EXPLORER_REMOTE,
|
||||
Box::new(FileList::new(
|
||||
PropsBuilder::default()
|
||||
FileListPropsBuilder::default()
|
||||
.with_background(Color::LightBlue)
|
||||
.with_foreground(Color::LightBlue)
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::LightBlue)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -86,12 +94,18 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_LOG_BOX,
|
||||
Box::new(LogBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
.bold()
|
||||
LogboxPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Mount status bar
|
||||
self.view.mount(
|
||||
super::COMPONENT_SPAN_STATUS_BAR,
|
||||
Box::new(Span::new(SpanPropsBuilder::default().build())),
|
||||
);
|
||||
// Load process bar
|
||||
self.refresh_status_bar();
|
||||
// Update components
|
||||
let _ = self.update_local_filelist();
|
||||
let _ = self.update_remote_filelist();
|
||||
@@ -125,16 +139,18 @@ impl FileTransferActivity {
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||
.direction(Direction::Horizontal)
|
||||
.split(chunks[0]);
|
||||
// Create log box chunks
|
||||
let bottom_chunks = Layout::default()
|
||||
.constraints([Constraint::Length(1), Constraint::Length(10)].as_ref())
|
||||
.direction(Direction::Vertical)
|
||||
.split(chunks[1]);
|
||||
// If width is unset in the storage, set width
|
||||
if !store.isset(super::STORAGE_EXPLORER_WIDTH) {
|
||||
store.set_unsigned(super::STORAGE_EXPLORER_WIDTH, tabs_chunks[0].width as usize);
|
||||
}
|
||||
if !store.isset(super::STORAGE_LOGBOX_WIDTH) {
|
||||
store.set_unsigned(super::STORAGE_LOGBOX_WIDTH, chunks[1].width as usize);
|
||||
}
|
||||
// Draw explorers
|
||||
// @! Local explorer (Find or default)
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::FindLocal => {
|
||||
self.view
|
||||
.render(super::COMPONENT_EXPLORER_FIND, f, tabs_chunks[0])
|
||||
@@ -144,7 +160,7 @@ impl FileTransferActivity {
|
||||
.render(super::COMPONENT_EXPLORER_LOCAL, f, tabs_chunks[0]),
|
||||
}
|
||||
// @! Remote explorer (Find or default)
|
||||
match self.tab {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::FindRemote => {
|
||||
self.view
|
||||
.render(super::COMPONENT_EXPLORER_FIND, f, tabs_chunks[1])
|
||||
@@ -153,99 +169,115 @@ impl FileTransferActivity {
|
||||
.view
|
||||
.render(super::COMPONENT_EXPLORER_REMOTE, f, tabs_chunks[1]),
|
||||
}
|
||||
// Draw log box
|
||||
self.view.render(super::COMPONENT_LOG_BOX, f, chunks[1]);
|
||||
// Draw log box and status bar
|
||||
self.view
|
||||
.render(super::COMPONENT_LOG_BOX, f, bottom_chunks[1]);
|
||||
self.view
|
||||
.render(super::COMPONENT_SPAN_STATUS_BAR, f, bottom_chunks[0]);
|
||||
// @! Draw popups
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_COPY) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_COPY) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_COPY, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_FIND) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_FIND) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_FIND, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_GOTO) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_GOTO) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_GOTO, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_MKDIR) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_MKDIR) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_MKDIR, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_NEWFILE) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_NEWFILE) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_NEWFILE, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_RENAME) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_RENAME) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_RENAME, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_SAVEAS) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_SAVEAS) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_SAVEAS, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_EXEC) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_EXEC) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_INPUT_EXEC, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_LIST_FILEINFO) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_LIST_FILEINFO) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 50);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_LIST_FILEINFO, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_PROGRESS_BAR) {
|
||||
if props.build().visible {
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_PROGRESS_BAR_PARTIAL) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 20);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_PROGRESS_BAR, f, popup);
|
||||
let popup_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage(50), // Full
|
||||
Constraint::Percentage(50), // Partial
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(popup);
|
||||
self.view
|
||||
.render(super::COMPONENT_PROGRESS_BAR_FULL, f, popup_chunks[0]);
|
||||
self.view
|
||||
.render(super::COMPONENT_PROGRESS_BAR_PARTIAL, f, popup_chunks[1]);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_DELETE) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DELETE) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_RADIO_DELETE, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_DISCONNECT) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DISCONNECT) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
@@ -253,48 +285,48 @@ impl FileTransferActivity {
|
||||
.render(super::COMPONENT_RADIO_DISCONNECT, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_RADIO_QUIT, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_SORTING) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_SORTING) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_RADIO_SORTING, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_TEXT_ERROR, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_FATAL) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_FATAL) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_TEXT_FATAL, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_WAIT) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_WAIT) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_TEXT_WAIT, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 80);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
@@ -316,10 +348,11 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_ERROR,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
MsgBoxPropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.with_texts(None, vec![TextSpan::from(text)])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -339,10 +372,11 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_FATAL,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
MsgBoxPropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.with_texts(None, vec![TextSpan::from(text)])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -355,10 +389,11 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_WAIT,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
MsgBoxPropsBuilder::default()
|
||||
.with_foreground(Color::White)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.with_texts(None, vec![TextSpan::from(text)])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -377,14 +412,15 @@ impl FileTransferActivity {
|
||||
// Protocol
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_QUIT,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::Yellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
|
||||
.with_options(
|
||||
Some(String::from("Are you sure you want to quit?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -405,14 +441,15 @@ impl FileTransferActivity {
|
||||
// Protocol
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_DISCONNECT,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::Yellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Yellow)
|
||||
.with_options(
|
||||
Some(String::from("Are you sure you want to disconnect?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -430,11 +467,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_COPY,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Insert destination name")),
|
||||
None,
|
||||
))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_label(String::from("Copy file(s) to..."))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -449,8 +484,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_EXEC,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("Execute command")), None))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::White)
|
||||
.with_label(String::from("Execute command"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -463,7 +499,7 @@ impl FileTransferActivity {
|
||||
|
||||
pub(super) fn mount_find(&mut self, search: &str) {
|
||||
// Get color
|
||||
let color: Color = match self.tab {
|
||||
let color: Color = match self.browser.tab() {
|
||||
FileExplorerTab::Local | FileExplorerTab::FindLocal => Color::Yellow,
|
||||
FileExplorerTab::Remote | FileExplorerTab::FindRemote => Color::LightBlue,
|
||||
};
|
||||
@@ -471,11 +507,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_EXPLORER_FIND,
|
||||
Box::new(FileList::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(format!("Search results for \"{}\"", search)),
|
||||
Some(vec![]),
|
||||
))
|
||||
FileListPropsBuilder::default()
|
||||
.with_files(Some(format!("Search results for \"{}\"", search)), vec![])
|
||||
.with_borders(Borders::ALL, BorderType::Plain, color)
|
||||
.with_background(color)
|
||||
.with_foreground(color)
|
||||
.build(),
|
||||
@@ -493,11 +527,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_FIND,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Search files by name")),
|
||||
None,
|
||||
))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_label(String::from("Search files by name"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -514,11 +546,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_GOTO,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Change working directory")),
|
||||
None,
|
||||
))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_label(String::from("Change working directory"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -533,11 +563,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_MKDIR,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Insert directory name")),
|
||||
None,
|
||||
))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_label(String::from("Insert directory name"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -552,8 +580,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_NEWFILE,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("New file name")), None))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_label(String::from("New file name"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -568,8 +597,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_RENAME,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("Insert new name")), None))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_label(String::from("Move file(s) to..."))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -584,8 +614,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_SAVEAS,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("Save as...")), None))
|
||||
InputPropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_label(String::from("Save as..."))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -596,28 +627,49 @@ impl FileTransferActivity {
|
||||
self.view.umount(super::COMPONENT_INPUT_SAVEAS);
|
||||
}
|
||||
|
||||
pub(super) fn mount_progress_bar(&mut self) {
|
||||
pub(super) fn mount_progress_bar(&mut self, root_name: String) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_PROGRESS_BAR,
|
||||
super::COMPONENT_PROGRESS_BAR_FULL,
|
||||
Box::new(ProgressBar::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
ProgressBarPropsBuilder::default()
|
||||
.with_progbar_color(Color::Green)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(Some(String::from("Please wait")), None))
|
||||
.with_borders(
|
||||
Borders::TOP | Borders::RIGHT | Borders::LEFT,
|
||||
BorderType::Rounded,
|
||||
Color::Reset,
|
||||
)
|
||||
.with_texts(Some(root_name), String::new())
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_PROGRESS_BAR);
|
||||
self.view.mount(
|
||||
super::COMPONENT_PROGRESS_BAR_PARTIAL,
|
||||
Box::new(ProgressBar::new(
|
||||
ProgressBarPropsBuilder::default()
|
||||
.with_progbar_color(Color::Green)
|
||||
.with_background(Color::Black)
|
||||
.with_borders(
|
||||
Borders::BOTTOM | Borders::RIGHT | Borders::LEFT,
|
||||
BorderType::Rounded,
|
||||
Color::Reset,
|
||||
)
|
||||
.with_texts(Some(String::from("Please wait")), String::new())
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_PROGRESS_BAR_PARTIAL);
|
||||
}
|
||||
|
||||
pub(super) fn umount_progress_bar(&mut self) {
|
||||
self.view.umount(super::COMPONENT_PROGRESS_BAR);
|
||||
self.view.umount(super::COMPONENT_PROGRESS_BAR_PARTIAL);
|
||||
self.view.umount(super::COMPONENT_PROGRESS_BAR_FULL);
|
||||
}
|
||||
|
||||
pub(super) fn mount_file_sorting(&mut self) {
|
||||
let sorting: FileSorting = match self.tab {
|
||||
FileExplorerTab::Local => self.local.get_file_sorting(),
|
||||
FileExplorerTab::Remote => self.remote.get_file_sorting(),
|
||||
let sorting: FileSorting = match self.browser.tab() {
|
||||
FileExplorerTab::Local => self.local().get_file_sorting(),
|
||||
FileExplorerTab::Remote => self.remote().get_file_sorting(),
|
||||
_ => panic!("You can't mount file sorting when in found result"),
|
||||
};
|
||||
let index: usize = match sorting {
|
||||
@@ -628,20 +680,21 @@ impl FileTransferActivity {
|
||||
};
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_SORTING,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightMagenta)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightMagenta)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta)
|
||||
.with_options(
|
||||
Some(String::from("Sort files by")),
|
||||
Some(vec![
|
||||
TextSpan::from("Name"),
|
||||
TextSpan::from("Modify time"),
|
||||
TextSpan::from("Creation time"),
|
||||
TextSpan::from("Size"),
|
||||
]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(index))
|
||||
vec![
|
||||
String::from("Name"),
|
||||
String::from("Modify time"),
|
||||
String::from("Creation time"),
|
||||
String::from("Size"),
|
||||
],
|
||||
)
|
||||
.with_value(index)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -655,15 +708,16 @@ impl FileTransferActivity {
|
||||
pub(super) fn mount_radio_delete(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_DELETE,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::Red)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::Red)
|
||||
.with_options(
|
||||
Some(String::from("Delete file")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.with_value(1)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -772,11 +826,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_LIST_FILEINFO,
|
||||
Box::new(Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
Some(file.get_name().to_string()),
|
||||
texts.build(),
|
||||
))
|
||||
TablePropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_table(Some(file.get_name().to_string()), texts.build())
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -787,6 +839,41 @@ impl FileTransferActivity {
|
||||
self.view.umount(super::COMPONENT_LIST_FILEINFO);
|
||||
}
|
||||
|
||||
pub(super) fn refresh_status_bar(&mut self) {
|
||||
let bar_spans: Vec<TextSpan> = vec![
|
||||
TextSpanBuilder::new("Synchronized Browsing: ")
|
||||
.with_foreground(Color::LightGreen)
|
||||
.build(),
|
||||
TextSpanBuilder::new(match self.browser.sync_browsing {
|
||||
true => "ON ",
|
||||
false => "OFF",
|
||||
})
|
||||
.with_foreground(Color::LightGreen)
|
||||
.reversed()
|
||||
.build(),
|
||||
TextSpanBuilder::new(" Localhost file sorting: ")
|
||||
.with_foreground(Color::LightYellow)
|
||||
.build(),
|
||||
TextSpanBuilder::new(Self::get_file_sorting_str(self.local().get_file_sorting()))
|
||||
.with_foreground(Color::LightYellow)
|
||||
.reversed()
|
||||
.build(),
|
||||
TextSpanBuilder::new(" Remote host file sorting: ")
|
||||
.with_foreground(Color::LightBlue)
|
||||
.build(),
|
||||
TextSpanBuilder::new(Self::get_file_sorting_str(self.remote().get_file_sorting()))
|
||||
.with_foreground(Color::LightBlue)
|
||||
.reversed()
|
||||
.build(),
|
||||
];
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_SPAN_STATUS_BAR) {
|
||||
self.view.update(
|
||||
super::COMPONENT_SPAN_STATUS_BAR,
|
||||
SpanPropsBuilder::from(props).with_spans(bar_spans).build(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### mount_help
|
||||
///
|
||||
/// Mount help
|
||||
@@ -794,8 +881,9 @@ impl FileTransferActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_HELP,
|
||||
Box::new(Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
TablePropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_table(
|
||||
Some(String::from("Help")),
|
||||
TableBuilder::default()
|
||||
.add_col(
|
||||
@@ -920,6 +1008,14 @@ impl FileTransferActivity {
|
||||
)
|
||||
.add_col(TextSpan::from(" Reload directory content"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<M>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Select file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<N>")
|
||||
.bold()
|
||||
@@ -968,6 +1064,22 @@ impl FileTransferActivity {
|
||||
)
|
||||
.add_col(TextSpan::from(" Go to parent directory"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<X>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Execute shell command"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<Y>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Toggle synchronized browsing"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<DEL|E>")
|
||||
.bold()
|
||||
@@ -976,6 +1088,14 @@ impl FileTransferActivity {
|
||||
)
|
||||
.add_col(TextSpan::from(" Delete selected file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+A>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Select all files"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+C>")
|
||||
.bold()
|
||||
@@ -984,7 +1104,7 @@ impl FileTransferActivity {
|
||||
)
|
||||
.add_col(TextSpan::from(" Interrupt file transfer"))
|
||||
.build(),
|
||||
))
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -995,4 +1115,13 @@ impl FileTransferActivity {
|
||||
pub(super) fn umount_help(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_HELP);
|
||||
}
|
||||
|
||||
fn get_file_sorting_str(mode: FileSorting) -> &'static str {
|
||||
match mode {
|
||||
FileSorting::ByName => "By name",
|
||||
FileSorting::ByCreationTime => "By creation time",
|
||||
FileSorting::ByModifyTime => "By modify time",
|
||||
FileSorting::BySize => "By size",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,640 +0,0 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{FileExplorerTab, FileTransferActivity, FsEntry, LogLevel};
|
||||
use crate::ui::layout::Payload;
|
||||
// externals
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### action_change_local_dir
|
||||
///
|
||||
/// Change local directory reading value from input
|
||||
pub(super) fn action_change_local_dir(&mut self, input: String) {
|
||||
let dir_path: PathBuf = PathBuf::from(input.as_str());
|
||||
let abs_dir_path: PathBuf = match dir_path.is_relative() {
|
||||
true => {
|
||||
let mut d: PathBuf = self.local.wrkdir.clone();
|
||||
d.push(dir_path);
|
||||
d
|
||||
}
|
||||
false => dir_path,
|
||||
};
|
||||
self.local_changedir(abs_dir_path.as_path(), true);
|
||||
}
|
||||
|
||||
/// ### action_change_remote_dir
|
||||
///
|
||||
/// Change remote directory reading value from input
|
||||
pub(super) fn action_change_remote_dir(&mut self, input: String) {
|
||||
let dir_path: PathBuf = PathBuf::from(input.as_str());
|
||||
let abs_dir_path: PathBuf = match dir_path.is_relative() {
|
||||
true => {
|
||||
let mut wrkdir: PathBuf = self.remote.wrkdir.clone();
|
||||
wrkdir.push(dir_path);
|
||||
wrkdir
|
||||
}
|
||||
false => dir_path,
|
||||
};
|
||||
self.remote_changedir(abs_dir_path.as_path(), true);
|
||||
}
|
||||
|
||||
/// ### action_local_copy
|
||||
///
|
||||
/// Copy file on local
|
||||
pub(super) fn action_local_copy(&mut self, input: String) {
|
||||
if let Some(idx) = self.get_local_file_idx() {
|
||||
let dest_path: PathBuf = PathBuf::from(input);
|
||||
let entry: FsEntry = self.local.get(idx).unwrap().clone();
|
||||
if let Some(ctx) = self.context.as_mut() {
|
||||
match ctx.local.copy(&entry, dest_path.as_path()) {
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Copied \"{}\" to \"{}\"",
|
||||
entry.get_abs_path().display(),
|
||||
dest_path.display()
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
// Reload entries
|
||||
let wrkdir: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(wrkdir.as_path());
|
||||
}
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not copy \"{}\" to \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
dest_path.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_remote_copy
|
||||
///
|
||||
/// Copy file on remote
|
||||
pub(super) fn action_remote_copy(&mut self, input: String) {
|
||||
if let Some(idx) = self.get_remote_file_idx() {
|
||||
let dest_path: PathBuf = PathBuf::from(input);
|
||||
let entry: FsEntry = self.remote.get(idx).unwrap().clone();
|
||||
match self.client.as_mut().copy(&entry, dest_path.as_path()) {
|
||||
Ok(_) => {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Copied \"{}\" to \"{}\"",
|
||||
entry.get_abs_path().display(),
|
||||
dest_path.display()
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not copy \"{}\" to \"{}\": {}",
|
||||
entry.get_abs_path().display(),
|
||||
dest_path.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_local_mkdir(&mut self, input: String) {
|
||||
match self
|
||||
.context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.local
|
||||
.mkdir(PathBuf::from(input.as_str()).as_path())
|
||||
{
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created directory \"{}\"", input).as_ref(),
|
||||
);
|
||||
let wrkdir: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(wrkdir.as_path());
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create directory \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn action_remote_mkdir(&mut self, input: String) {
|
||||
match self
|
||||
.client
|
||||
.as_mut()
|
||||
.mkdir(PathBuf::from(input.as_str()).as_path())
|
||||
{
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created directory \"{}\"", input).as_ref(),
|
||||
);
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create directory \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_local_rename(&mut self, input: String) {
|
||||
let entry: Option<FsEntry> = self.get_local_file_entry().cloned();
|
||||
if let Some(entry) = entry {
|
||||
let mut dst_path: PathBuf = PathBuf::from(input);
|
||||
// Check if path is relative
|
||||
if dst_path.as_path().is_relative() {
|
||||
let mut wrkdir: PathBuf = self.local.wrkdir.clone();
|
||||
wrkdir.push(dst_path);
|
||||
dst_path = wrkdir;
|
||||
}
|
||||
let full_path: PathBuf = entry.get_abs_path();
|
||||
// Rename file or directory and report status as popup
|
||||
match self
|
||||
.context
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.local
|
||||
.rename(&entry, dst_path.as_path())
|
||||
{
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
let path: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(path.as_path());
|
||||
// Log
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Renamed file \"{}\" to \"{}\"",
|
||||
full_path.display(),
|
||||
dst_path.display()
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not rename file \"{}\": {}", full_path.display(), err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_remote_rename(&mut self, input: String) {
|
||||
if let Some(idx) = self.get_remote_file_idx() {
|
||||
if let Some(entry) = self.remote.get(idx) {
|
||||
let dst_path: PathBuf = PathBuf::from(input);
|
||||
let full_path: PathBuf = entry.get_abs_path();
|
||||
// Rename file or directory and report status as popup
|
||||
match self.client.as_mut().rename(entry, dst_path.as_path()) {
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
let path: PathBuf = self.remote.wrkdir.clone();
|
||||
self.remote_scan(path.as_path());
|
||||
// Log
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Renamed file \"{}\" to \"{}\"",
|
||||
full_path.display(),
|
||||
dst_path.display()
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not rename file \"{}\": {}", full_path.display(), err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_local_delete(&mut self) {
|
||||
let entry: Option<FsEntry> = self.get_local_file_entry().cloned();
|
||||
if let Some(entry) = entry {
|
||||
let full_path: PathBuf = entry.get_abs_path();
|
||||
// Delete file or directory and report status as popup
|
||||
match self.context.as_mut().unwrap().local.remove(&entry) {
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
let p: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(p.as_path());
|
||||
// Log
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Removed file \"{}\"", full_path.display()).as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not delete file \"{}\": {}", full_path.display(), err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_remote_delete(&mut self) {
|
||||
if let Some(idx) = self.get_remote_file_idx() {
|
||||
// Check if file entry exists
|
||||
if let Some(entry) = self.remote.get(idx) {
|
||||
let full_path: PathBuf = entry.get_abs_path();
|
||||
// Delete file
|
||||
match self.client.remove(entry) {
|
||||
Ok(_) => {
|
||||
self.reload_remote_dir();
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Removed file \"{}\"", full_path.display()).as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not delete file \"{}\": {}", full_path.display(), err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_local_saveas(&mut self, input: String) {
|
||||
if let Some(idx) = self.get_local_file_idx() {
|
||||
// Get pwd
|
||||
let wrkdir: PathBuf = self.remote.wrkdir.clone();
|
||||
if self.local.get(idx).is_some() {
|
||||
let file: FsEntry = self.local.get(idx).unwrap().clone();
|
||||
// Call upload; pass realfile, keep link name
|
||||
self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(input));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_remote_saveas(&mut self, input: String) {
|
||||
if let Some(idx) = self.get_remote_file_idx() {
|
||||
// Get pwd
|
||||
let wrkdir: PathBuf = self.local.wrkdir.clone();
|
||||
if self.remote.get(idx).is_some() {
|
||||
let file: FsEntry = self.remote.get(idx).unwrap().clone();
|
||||
// Call upload; pass realfile, keep link name
|
||||
self.filetransfer_recv(&file.get_realfile(), wrkdir.as_path(), Some(input));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_local_newfile(&mut self, input: String) {
|
||||
// Check if file exists
|
||||
let mut file_exists: bool = false;
|
||||
for file in self.local.iter_files_all() {
|
||||
if input == file.get_name() {
|
||||
file_exists = true;
|
||||
}
|
||||
}
|
||||
if file_exists {
|
||||
self.log_and_alert(
|
||||
LogLevel::Warn,
|
||||
format!("File \"{}\" already exists", input,),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Create file
|
||||
let file_path: PathBuf = PathBuf::from(input.as_str());
|
||||
if let Some(ctx) = self.context.as_mut() {
|
||||
if let Err(err) = ctx.local.open_file_write(file_path.as_path()) {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create file \"{}\": {}", file_path.display(), err),
|
||||
);
|
||||
} else {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created file \"{}\"", file_path.display()).as_str(),
|
||||
);
|
||||
}
|
||||
// Reload files
|
||||
let path: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(path.as_path());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_remote_newfile(&mut self, input: String) {
|
||||
// Check if file exists
|
||||
let mut file_exists: bool = false;
|
||||
for file in self.remote.iter_files_all() {
|
||||
if input == file.get_name() {
|
||||
file_exists = true;
|
||||
}
|
||||
}
|
||||
if file_exists {
|
||||
self.log_and_alert(
|
||||
LogLevel::Warn,
|
||||
format!("File \"{}\" already exists", input,),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Get path on remote
|
||||
let file_path: PathBuf = PathBuf::from(input.as_str());
|
||||
// Create file (on local)
|
||||
match tempfile::NamedTempFile::new() {
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not create tempfile: {}", err),
|
||||
),
|
||||
Ok(tfile) => {
|
||||
// Stat tempfile
|
||||
if let Some(ctx) = self.context.as_mut() {
|
||||
let local_file: FsEntry = match ctx.local.stat(tfile.path()) {
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not stat tempfile: {}", err),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Ok(f) => f,
|
||||
};
|
||||
if let FsEntry::File(local_file) = local_file {
|
||||
// Create file
|
||||
match self.client.send_file(&local_file, file_path.as_path()) {
|
||||
Err(err) => self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not create file \"{}\": {}",
|
||||
file_path.display(),
|
||||
err
|
||||
),
|
||||
),
|
||||
Ok(writer) => {
|
||||
// Finalize write
|
||||
if let Err(err) = self.client.on_sent(writer) {
|
||||
self.log_and_alert(
|
||||
LogLevel::Warn,
|
||||
format!("Could not finalize file: {}", err),
|
||||
);
|
||||
} else {
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Created file \"{}\"", file_path.display())
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
// Reload files
|
||||
let path: PathBuf = self.remote.wrkdir.clone();
|
||||
self.remote_scan(path.as_path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_local_exec(&mut self, input: String) {
|
||||
match self.context.as_mut().unwrap().local.exec(input.as_str()) {
|
||||
Ok(output) => {
|
||||
// Reload files
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("\"{}\": {}", input, output).as_ref(),
|
||||
);
|
||||
let wrkdir: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(wrkdir.as_path());
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not execute command \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_remote_exec(&mut self, input: String) {
|
||||
match self.client.as_mut().exec(input.as_str()) {
|
||||
Ok(output) => {
|
||||
// Reload files
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("\"{}\": {}", input, output).as_ref(),
|
||||
);
|
||||
self.reload_remote_dir();
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not execute command \"{}\": {}", input, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_local_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
|
||||
match self.context.as_mut().unwrap().local.find(input.as_str()) {
|
||||
Ok(entries) => Ok(entries),
|
||||
Err(err) => Err(format!("Could not search for files: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_remote_find(&mut self, input: String) -> Result<Vec<FsEntry>, String> {
|
||||
match self.client.as_mut().find(input.as_str()) {
|
||||
Ok(entries) => Ok(entries),
|
||||
Err(err) => Err(format!("Could not search for files: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_find_changedir(&mut self, idx: usize) {
|
||||
// Match entry
|
||||
if let Some(entry) = self.found.as_ref().unwrap().get(idx) {
|
||||
// Get path: if a directory, use directory path; if it is a File, get parent path
|
||||
let path: PathBuf = match entry {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => match file.abs_path.parent() {
|
||||
None => PathBuf::from("."),
|
||||
Some(p) => p.to_path_buf(),
|
||||
},
|
||||
};
|
||||
// Change directory
|
||||
match self.tab {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
|
||||
self.local_changedir(path.as_path(), true)
|
||||
}
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
|
||||
self.remote_changedir(path.as_path(), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_find_transfer(&mut self, idx: usize, name: Option<String>) {
|
||||
let entry: Option<FsEntry> = self.found.as_ref().unwrap().get(idx).cloned();
|
||||
if let Some(entry) = entry {
|
||||
// Download file
|
||||
match self.tab {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
|
||||
let wrkdir: PathBuf = self.remote.wrkdir.clone();
|
||||
self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), name);
|
||||
}
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
|
||||
let wrkdir: PathBuf = self.local.wrkdir.clone();
|
||||
self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn action_find_delete(&mut self, idx: usize) {
|
||||
let entry: Option<FsEntry> = self.found.as_ref().unwrap().get(idx).cloned();
|
||||
if let Some(entry) = entry {
|
||||
// Download file
|
||||
match self.tab {
|
||||
FileExplorerTab::FindLocal | FileExplorerTab::Local => {
|
||||
let full_path: PathBuf = entry.get_abs_path();
|
||||
// Delete file or directory and report status as popup
|
||||
match self.context.as_mut().unwrap().local.remove(&entry) {
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
let p: PathBuf = self.local.wrkdir.clone();
|
||||
self.local_scan(p.as_path());
|
||||
// Log
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Removed file \"{}\"", full_path.display()).as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not delete file \"{}\": {}",
|
||||
full_path.display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
FileExplorerTab::FindRemote | FileExplorerTab::Remote => {
|
||||
let full_path: PathBuf = entry.get_abs_path();
|
||||
// Delete file
|
||||
match self.client.remove(&entry) {
|
||||
Ok(_) => {
|
||||
self.reload_remote_dir();
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Removed file \"{}\"", full_path.display()).as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not delete file \"{}\": {}",
|
||||
full_path.display(),
|
||||
err
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_local_file_entry
|
||||
///
|
||||
/// Get local file entry
|
||||
pub(super) fn get_local_file_entry(&self) -> Option<&FsEntry> {
|
||||
match self.get_local_file_idx() {
|
||||
None => None,
|
||||
Some(idx) => self.local.get(idx),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_remote_file_entry
|
||||
///
|
||||
/// Get remote file entry
|
||||
pub(super) fn get_remote_file_entry(&self) -> Option<&FsEntry> {
|
||||
match self.get_remote_file_idx() {
|
||||
None => None,
|
||||
Some(idx) => self.remote.get(idx),
|
||||
}
|
||||
}
|
||||
|
||||
// -- private
|
||||
|
||||
/// ### get_local_file_idx
|
||||
///
|
||||
/// Get index of selected file in the local tab
|
||||
fn get_local_file_idx(&self) -> Option<usize> {
|
||||
match self.view.get_value(super::COMPONENT_EXPLORER_LOCAL) {
|
||||
Some(Payload::Unsigned(idx)) => Some(idx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_remote_file_idx
|
||||
///
|
||||
/// Get index of selected file in the remote file
|
||||
fn get_remote_file_idx(&self) -> Option<usize> {
|
||||
match self.view.get_value(super::COMPONENT_EXPLORER_REMOTE) {
|
||||
Some(Payload::Unsigned(idx)) => Some(idx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
*/
|
||||
// Locals
|
||||
use super::SetupActivity;
|
||||
use crate::ui::layout::Payload;
|
||||
// Ext
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use std::env;
|
||||
use tuirealm::{Payload, Value};
|
||||
|
||||
impl SetupActivity {
|
||||
/// ### action_save_config
|
||||
@@ -63,8 +63,8 @@ impl SetupActivity {
|
||||
// Get key
|
||||
if let Some(config_cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
// get index
|
||||
let idx: Option<usize> = match self.view.get_value(super::COMPONENT_LIST_SSH_KEYS) {
|
||||
Some(Payload::Unsigned(idx)) => Some(idx),
|
||||
let idx: Option<usize> = match self.view.get_state(super::COMPONENT_LIST_SSH_KEYS) {
|
||||
Some(Payload::One(Value::Usize(idx))) => Some(idx),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(idx) = idx {
|
||||
@@ -99,25 +99,29 @@ impl SetupActivity {
|
||||
pub(super) fn action_new_ssh_key(&mut self) {
|
||||
if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
// get parameters
|
||||
let host: String = match self.view.get_value(super::COMPONENT_INPUT_SSH_HOST) {
|
||||
Some(Payload::Text(host)) => host,
|
||||
let host: String = match self.view.get_state(super::COMPONENT_INPUT_SSH_HOST) {
|
||||
Some(Payload::One(Value::Str(host))) => host,
|
||||
_ => String::new(),
|
||||
};
|
||||
let username: String = match self.view.get_value(super::COMPONENT_INPUT_SSH_USERNAME) {
|
||||
Some(Payload::Text(user)) => user,
|
||||
let username: String = match self.view.get_state(super::COMPONENT_INPUT_SSH_USERNAME) {
|
||||
Some(Payload::One(Value::Str(user))) => user,
|
||||
_ => String::new(),
|
||||
};
|
||||
// Prepare text editor
|
||||
env::set_var("EDITOR", cli.get_text_editor());
|
||||
let placeholder: String = format!("# Type private SSH key for {}@{}\n", username, host);
|
||||
// Put input mode back to normal
|
||||
let _ = disable_raw_mode();
|
||||
if let Err(err) = disable_raw_mode() {
|
||||
error!("Failed to disable raw mode: {}", err);
|
||||
}
|
||||
// Leave alternate mode
|
||||
if let Some(ctx) = self.context.as_mut() {
|
||||
ctx.leave_alternate_screen();
|
||||
}
|
||||
// Re-enable raw mode
|
||||
let _ = enable_raw_mode();
|
||||
if let Err(err) = enable_raw_mode() {
|
||||
error!("Failed to enter raw mode: {}", err);
|
||||
}
|
||||
// Write key to file
|
||||
match edit::edit(placeholder.as_bytes()) {
|
||||
Ok(rsa_key) => {
|
||||
@@ -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
|
||||
}
|
||||
@@ -34,13 +34,13 @@ mod view;
|
||||
|
||||
// Deps
|
||||
extern crate crossterm;
|
||||
extern crate tui;
|
||||
extern crate tuirealm;
|
||||
|
||||
// Locals
|
||||
use super::{Activity, Context, ExitReason};
|
||||
use crate::ui::layout::view::View;
|
||||
// Ext
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use tuirealm::View;
|
||||
|
||||
// -- components
|
||||
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
|
||||
@@ -53,7 +53,8 @@ const COMPONENT_RADIO_DEFAULT_PROTOCOL: &str = "RADIO_DEFAULT_PROTOCOL";
|
||||
const COMPONENT_RADIO_HIDDEN_FILES: &str = "RADIO_HIDDEN_FILES";
|
||||
const COMPONENT_RADIO_UPDATES: &str = "RADIO_CHECK_UPDATES";
|
||||
const COMPONENT_RADIO_GROUP_DIRS: &str = "RADIO_GROUP_DIRS";
|
||||
const COMPONENT_INPUT_FILE_FMT: &str = "INPUT_FILE_FMT";
|
||||
const COMPONENT_INPUT_LOCAL_FILE_FMT: &str = "INPUT_LOCAL_FILE_FMT";
|
||||
const COMPONENT_INPUT_REMOTE_FILE_FMT: &str = "INPUT_REMOTE_FILE_FMT";
|
||||
const COMPONENT_RADIO_TAB: &str = "RADIO_TAB";
|
||||
const COMPONENT_LIST_SSH_KEYS: &str = "LIST_SSH_KEYS";
|
||||
const COMPONENT_INPUT_SSH_HOST: &str = "INPUT_SSH_HOST";
|
||||
@@ -109,7 +110,9 @@ impl Activity for SetupActivity {
|
||||
// Clear terminal
|
||||
self.context.as_mut().unwrap().clear_screen();
|
||||
// Put raw mode on enabled
|
||||
let _ = enable_raw_mode();
|
||||
if let Err(err) = enable_raw_mode() {
|
||||
error!("Failed to enter raw mode: {}", err);
|
||||
}
|
||||
// Init view
|
||||
self.init_setup();
|
||||
// Verify error state from context
|
||||
@@ -160,7 +163,9 @@ impl Activity for SetupActivity {
|
||||
/// This function finally releases the context
|
||||
fn on_destroy(&mut self) -> Option<Context> {
|
||||
// Disable raw mode
|
||||
let _ = disable_raw_mode();
|
||||
if let Err(err) = disable_raw_mode() {
|
||||
error!("Failed to disable raw mode: {}", err);
|
||||
}
|
||||
self.context.as_ref()?;
|
||||
// Clear terminal and return
|
||||
match self.context.take() {
|
||||
@@ -28,14 +28,16 @@
|
||||
*/
|
||||
// locals
|
||||
use super::{
|
||||
SetupActivity, COMPONENT_INPUT_FILE_FMT, COMPONENT_INPUT_SSH_HOST,
|
||||
COMPONENT_INPUT_SSH_USERNAME, COMPONENT_INPUT_TEXT_EDITOR, COMPONENT_LIST_SSH_KEYS,
|
||||
COMPONENT_RADIO_DEFAULT_PROTOCOL, COMPONENT_RADIO_DEL_SSH_KEY, COMPONENT_RADIO_GROUP_DIRS,
|
||||
COMPONENT_RADIO_HIDDEN_FILES, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE,
|
||||
COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP,
|
||||
SetupActivity, COMPONENT_INPUT_LOCAL_FILE_FMT, COMPONENT_INPUT_REMOTE_FILE_FMT,
|
||||
COMPONENT_INPUT_SSH_HOST, COMPONENT_INPUT_SSH_USERNAME, COMPONENT_INPUT_TEXT_EDITOR,
|
||||
COMPONENT_LIST_SSH_KEYS, COMPONENT_RADIO_DEFAULT_PROTOCOL, COMPONENT_RADIO_DEL_SSH_KEY,
|
||||
COMPONENT_RADIO_GROUP_DIRS, COMPONENT_RADIO_HIDDEN_FILES, COMPONENT_RADIO_QUIT,
|
||||
COMPONENT_RADIO_SAVE, COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP,
|
||||
};
|
||||
use crate::ui::activities::keymap::*;
|
||||
use crate::ui::layout::{Msg, Payload};
|
||||
use crate::ui::keymap::*;
|
||||
|
||||
// ext
|
||||
use tuirealm::{Msg, Payload, Value};
|
||||
|
||||
impl SetupActivity {
|
||||
/// ### update
|
||||
@@ -66,15 +68,23 @@ impl SetupActivity {
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_GROUP_DIRS, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_INPUT_FILE_FMT);
|
||||
self.view.active(COMPONENT_INPUT_LOCAL_FILE_FMT);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_FILE_FMT, &MSG_KEY_DOWN) => {
|
||||
(COMPONENT_INPUT_LOCAL_FILE_FMT, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_REMOTE_FILE_FMT, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_INPUT_TEXT_EDITOR);
|
||||
None
|
||||
}
|
||||
// Input field <UP>
|
||||
(COMPONENT_INPUT_FILE_FMT, &MSG_KEY_UP) => {
|
||||
(COMPONENT_INPUT_REMOTE_FILE_FMT, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_INPUT_LOCAL_FILE_FMT);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_LOCAL_FILE_FMT, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_RADIO_GROUP_DIRS);
|
||||
None
|
||||
}
|
||||
@@ -95,7 +105,7 @@ impl SetupActivity {
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_TEXT_EDITOR, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_INPUT_FILE_FMT);
|
||||
self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT);
|
||||
None
|
||||
}
|
||||
// Error <ENTER> or <ESC>
|
||||
@@ -105,7 +115,7 @@ impl SetupActivity {
|
||||
None
|
||||
}
|
||||
// Exit
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
|
||||
// Save changes
|
||||
if let Err(err) = self.action_save_config() {
|
||||
self.mount_error(err.as_str());
|
||||
@@ -114,7 +124,7 @@ impl SetupActivity {
|
||||
self.exit_reason = Some(super::ExitReason::Quit);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::One(Value::Usize(1)))) => {
|
||||
// Quit
|
||||
self.exit_reason = Some(super::ExitReason::Quit);
|
||||
self.umount_quit();
|
||||
@@ -132,7 +142,7 @@ impl SetupActivity {
|
||||
None
|
||||
}
|
||||
// Delete key
|
||||
(COMPONENT_RADIO_DEL_SSH_KEY, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
(COMPONENT_RADIO_DEL_SSH_KEY, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
|
||||
// Delete key
|
||||
self.action_delete_ssh_key();
|
||||
// Reload ssh keys
|
||||
@@ -147,7 +157,7 @@ impl SetupActivity {
|
||||
None
|
||||
}
|
||||
// Save popup
|
||||
(COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
(COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::One(Value::Usize(0)))) => {
|
||||
// Save config
|
||||
if let Err(err) = self.action_save_config() {
|
||||
self.mount_error(err.as_str());
|
||||
@@ -216,7 +226,7 @@ impl SetupActivity {
|
||||
None
|
||||
}
|
||||
// <ENTER> Edit key
|
||||
(COMPONENT_LIST_SSH_KEYS, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
(COMPONENT_LIST_SSH_KEYS, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => {
|
||||
// Edit ssh key
|
||||
if let Err(err) = self.edit_ssh_key(*idx) {
|
||||
self.mount_error(err.as_str());
|
||||
@@ -30,22 +30,27 @@
|
||||
use super::{Context, SetupActivity, ViewLayout};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::fs::explorer::GroupDirs;
|
||||
use crate::ui::layout::components::{
|
||||
bookmark_list::BookmarkList, input::Input, msgbox::MsgBox, radio_group::RadioGroup,
|
||||
table::Table, text::Text,
|
||||
use crate::ui::components::{
|
||||
bookmark_list::{BookmarkList, BookmarkListPropsBuilder},
|
||||
msgbox::{MsgBox, MsgBoxPropsBuilder},
|
||||
};
|
||||
use crate::ui::layout::props::{
|
||||
PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
|
||||
};
|
||||
use crate::ui::layout::utils::draw_area_in;
|
||||
use crate::ui::layout::view::View;
|
||||
use crate::ui::layout::Payload;
|
||||
use crate::utils::ui::draw_area_in;
|
||||
// Ext
|
||||
use std::path::PathBuf;
|
||||
use tui::{
|
||||
use tuirealm::components::{
|
||||
input::{Input, InputPropsBuilder},
|
||||
radio::{Radio, RadioPropsBuilder},
|
||||
span::{Span, SpanPropsBuilder},
|
||||
table::{Table, TablePropsBuilder},
|
||||
};
|
||||
use tuirealm::tui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
widgets::{Borders, Clear},
|
||||
widgets::{BorderType, Borders, Clear},
|
||||
};
|
||||
use tuirealm::{
|
||||
props::{PropsBuilder, TableBuilder, TextSpan, TextSpanBuilder},
|
||||
Payload, Value, View,
|
||||
};
|
||||
|
||||
impl SetupActivity {
|
||||
@@ -61,38 +66,32 @@ impl SetupActivity {
|
||||
// Radio tab
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_TAB,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightYellow)
|
||||
.with_background(Color::Black)
|
||||
.with_borders(Borders::BOTTOM)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightYellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::BOTTOM, BorderType::Thick, Color::White)
|
||||
.with_options(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpan::from("User Interface"),
|
||||
TextSpan::from("SSH Keys"),
|
||||
]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(0))
|
||||
vec![String::from("User Interface"), String::from("SSH Keys")],
|
||||
)
|
||||
.with_value(0)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Footer
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_FOOTER,
|
||||
Box::new(Text::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpanBuilder::new("Press ").bold().build(),
|
||||
TextSpanBuilder::new("<CTRL+H>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to show keybindings").bold().build(),
|
||||
]),
|
||||
))
|
||||
Box::new(Span::new(
|
||||
SpanPropsBuilder::default()
|
||||
.with_spans(vec![
|
||||
TextSpanBuilder::new("Press ").bold().build(),
|
||||
TextSpanBuilder::new("<CTRL+H>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to show keybindings").bold().build(),
|
||||
])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -100,83 +99,96 @@ impl SetupActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_TEXT_EDITOR,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
.with_texts(TextParts::new(Some(String::from("Text editor")), None))
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen)
|
||||
.with_label(String::from("Text editor"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_TEXT_EDITOR); // <-- Focus
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_DEFAULT_PROTOCOL,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightCyan)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightCyan)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan)
|
||||
.with_options(
|
||||
Some(String::from("Default file transfer protocol")),
|
||||
Some(vec![
|
||||
TextSpan::from("SFTP"),
|
||||
TextSpan::from("SCP"),
|
||||
TextSpan::from("FTP"),
|
||||
TextSpan::from("FTPS"),
|
||||
]),
|
||||
))
|
||||
vec![
|
||||
String::from("SFTP"),
|
||||
String::from("SCP"),
|
||||
String::from("FTP"),
|
||||
String::from("FTPS"),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_HIDDEN_FILES,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightRed)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightRed)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed)
|
||||
.with_options(
|
||||
Some(String::from("Show hidden files (by default)")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_UPDATES,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightYellow)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightYellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow)
|
||||
.with_options(
|
||||
Some(String::from("Check for updates?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_GROUP_DIRS,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightMagenta)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightMagenta)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightMagenta)
|
||||
.with_options(
|
||||
Some(String::from("Group directories")),
|
||||
Some(vec![
|
||||
TextSpan::from("Display first"),
|
||||
TextSpan::from("Display Last"),
|
||||
TextSpan::from("No"),
|
||||
]),
|
||||
))
|
||||
vec![
|
||||
String::from("Display first"),
|
||||
String::from("Display Last"),
|
||||
String::from("No"),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_FILE_FMT,
|
||||
super::COMPONENT_INPUT_LOCAL_FILE_FMT,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::LightBlue)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("File formatter syntax")),
|
||||
None,
|
||||
))
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue)
|
||||
.with_label(String::from("File formatter syntax (local)"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_REMOTE_FILE_FMT,
|
||||
Box::new(Input::new(
|
||||
InputPropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen)
|
||||
.with_label(String::from("File formatter syntax (remote)"))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -196,46 +208,41 @@ impl SetupActivity {
|
||||
// Radio tab
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_TAB,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightYellow)
|
||||
.with_background(Color::Black)
|
||||
.with_borders(Borders::BOTTOM)
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightYellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::BOTTOM, BorderType::Thick, Color::LightYellow)
|
||||
.with_options(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpan::from("User Interface"),
|
||||
TextSpan::from("SSH Keys"),
|
||||
]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
vec![String::from("User Interface"), String::from("SSH Keys")],
|
||||
)
|
||||
.with_value(1)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Footer
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_FOOTER,
|
||||
Box::new(Text::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpanBuilder::new("Press ").bold().build(),
|
||||
TextSpanBuilder::new("<CTRL+H>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to show keybindings").bold().build(),
|
||||
]),
|
||||
))
|
||||
Box::new(Span::new(
|
||||
SpanPropsBuilder::default()
|
||||
.with_spans(vec![
|
||||
TextSpanBuilder::new("Press ").bold().build(),
|
||||
TextSpanBuilder::new("<CTRL+H>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
TextSpanBuilder::new(" to show keybindings").bold().build(),
|
||||
])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_LIST_SSH_KEYS,
|
||||
Box::new(BookmarkList::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("SSH Keys")), Some(vec![])))
|
||||
BookmarkListPropsBuilder::default()
|
||||
.with_bookmarks(Some(String::from("SSH Keys")), vec![])
|
||||
.with_borders(Borders::ALL, BorderType::Plain, Color::LightGreen)
|
||||
.with_background(Color::LightGreen)
|
||||
.with_foreground(Color::Black)
|
||||
.build(),
|
||||
@@ -283,7 +290,8 @@ impl SetupActivity {
|
||||
Constraint::Length(3), // Hidden files
|
||||
Constraint::Length(3), // Updates tab
|
||||
Constraint::Length(3), // Group dirs
|
||||
Constraint::Length(3), // Format input
|
||||
Constraint::Length(3), // Local Format input
|
||||
Constraint::Length(3), // Remote Format input
|
||||
Constraint::Length(1), // Empty ?
|
||||
]
|
||||
.as_ref(),
|
||||
@@ -300,7 +308,9 @@ impl SetupActivity {
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks[4]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_FILE_FMT, f, ui_cfg_chunks[5]);
|
||||
.render(super::COMPONENT_INPUT_LOCAL_FILE_FMT, f, ui_cfg_chunks[5]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_REMOTE_FILE_FMT, f, ui_cfg_chunks[6]);
|
||||
}
|
||||
ViewLayout::SshKeys => {
|
||||
let sshcfg_chunks = Layout::default()
|
||||
@@ -312,40 +322,40 @@ impl SetupActivity {
|
||||
}
|
||||
}
|
||||
// Popups
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
|
||||
if props.visible {
|
||||
let popup = draw_area_in(f.size(), 50, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_TEXT_ERROR, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 40, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view.render(super::COMPONENT_RADIO_QUIT, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_HELP) {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 50, 70);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view.render(super::COMPONENT_TEXT_HELP, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_SAVE) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_SAVE) {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view.render(super::COMPONENT_RADIO_SAVE, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_DEL_SSH_KEY) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEL_SSH_KEY) {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
@@ -353,8 +363,8 @@ impl SetupActivity {
|
||||
.render(super::COMPONENT_RADIO_DEL_SSH_KEY, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_SSH_HOST) {
|
||||
if props.build().visible {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_SSH_HOST) {
|
||||
if props.visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 50, 20);
|
||||
f.render_widget(Clear, popup);
|
||||
@@ -389,10 +399,11 @@ impl SetupActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_ERROR,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
MsgBoxPropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::Red)
|
||||
.with_texts(None, vec![TextSpan::from(text)])
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -413,15 +424,16 @@ impl SetupActivity {
|
||||
pub(super) fn mount_del_ssh_key(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_DEL_SSH_KEY,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightRed)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightRed)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed)
|
||||
.with_options(
|
||||
Some(String::from("Delete key?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1)) // Default: No
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.with_value(1) // Default: No
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -443,21 +455,26 @@ impl SetupActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_SSH_HOST,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Hostname or address")),
|
||||
None,
|
||||
))
|
||||
.with_borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
|
||||
InputPropsBuilder::default()
|
||||
.with_label(String::from("Hostname or address"))
|
||||
.with_borders(
|
||||
Borders::TOP | Borders::RIGHT | Borders::LEFT,
|
||||
BorderType::Plain,
|
||||
Color::Reset,
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_SSH_USERNAME,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("Username")), None))
|
||||
.with_borders(Borders::BOTTOM | Borders::RIGHT | Borders::LEFT)
|
||||
InputPropsBuilder::default()
|
||||
.with_label(String::from("Username"))
|
||||
.with_borders(
|
||||
Borders::BOTTOM | Borders::RIGHT | Borders::LEFT,
|
||||
BorderType::Plain,
|
||||
Color::Reset,
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -478,18 +495,19 @@ impl SetupActivity {
|
||||
pub(super) fn mount_quit(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_QUIT,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightRed)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightRed)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed)
|
||||
.with_options(
|
||||
Some(String::from("Exit setup?")),
|
||||
Some(vec![
|
||||
TextSpan::from("Save"),
|
||||
TextSpan::from("Don't save"),
|
||||
TextSpan::from("Cancel"),
|
||||
]),
|
||||
))
|
||||
vec![
|
||||
String::from("Save"),
|
||||
String::from("Don't save"),
|
||||
String::from("Cancel"),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -510,14 +528,15 @@ impl SetupActivity {
|
||||
pub(super) fn mount_save_popup(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_SAVE,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightYellow)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(
|
||||
Box::new(Radio::new(
|
||||
RadioPropsBuilder::default()
|
||||
.with_color(Color::LightYellow)
|
||||
.with_inverted_color(Color::Black)
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow)
|
||||
.with_options(
|
||||
Some(String::from("Save changes?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
vec![String::from("Yes"), String::from("No")],
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -539,8 +558,9 @@ impl SetupActivity {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_HELP,
|
||||
Box::new(Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
TablePropsBuilder::default()
|
||||
.with_borders(Borders::ALL, BorderType::Rounded, Color::White)
|
||||
.with_table(
|
||||
Some(String::from("Help")),
|
||||
TableBuilder::default()
|
||||
.add_col(
|
||||
@@ -615,7 +635,7 @@ impl SetupActivity {
|
||||
)
|
||||
.add_col(TextSpan::from(" Save configuration"))
|
||||
.build(),
|
||||
))
|
||||
)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
@@ -636,78 +656,70 @@ impl SetupActivity {
|
||||
pub(super) fn load_input_values(&mut self) {
|
||||
if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
// Text editor
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_INPUT_TEXT_EDITOR)
|
||||
.as_mut()
|
||||
{
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_TEXT_EDITOR) {
|
||||
let text_editor: String =
|
||||
String::from(cli.get_text_editor().as_path().to_string_lossy());
|
||||
let props = props.with_value(PropValue::Str(text_editor)).build();
|
||||
let props = InputPropsBuilder::from(props)
|
||||
.with_value(text_editor)
|
||||
.build();
|
||||
let _ = self.view.update(super::COMPONENT_INPUT_TEXT_EDITOR, props);
|
||||
}
|
||||
// Protocol
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_DEFAULT_PROTOCOL)
|
||||
.as_mut()
|
||||
{
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_DEFAULT_PROTOCOL) {
|
||||
let protocol: usize = match cli.get_default_protocol() {
|
||||
FileTransferProtocol::Sftp => 0,
|
||||
FileTransferProtocol::Scp => 1,
|
||||
FileTransferProtocol::Ftp(false) => 2,
|
||||
FileTransferProtocol::Ftp(true) => 3,
|
||||
};
|
||||
let props = props.with_value(PropValue::Unsigned(protocol)).build();
|
||||
let props = RadioPropsBuilder::from(props).with_value(protocol).build();
|
||||
let _ = self
|
||||
.view
|
||||
.update(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, props);
|
||||
}
|
||||
// Hidden files
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_HIDDEN_FILES)
|
||||
.as_mut()
|
||||
{
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_HIDDEN_FILES) {
|
||||
let hidden: usize = match cli.get_show_hidden_files() {
|
||||
true => 0,
|
||||
false => 1,
|
||||
};
|
||||
let props = props.with_value(PropValue::Unsigned(hidden)).build();
|
||||
let props = RadioPropsBuilder::from(props).with_value(hidden).build();
|
||||
let _ = self.view.update(super::COMPONENT_RADIO_HIDDEN_FILES, props);
|
||||
}
|
||||
// Updates
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_UPDATES).as_mut() {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_UPDATES) {
|
||||
let updates: usize = match cli.get_check_for_updates() {
|
||||
true => 0,
|
||||
false => 1,
|
||||
};
|
||||
let props = props.with_value(PropValue::Unsigned(updates)).build();
|
||||
let props = RadioPropsBuilder::from(props).with_value(updates).build();
|
||||
let _ = self.view.update(super::COMPONENT_RADIO_UPDATES, props);
|
||||
}
|
||||
// Group dirs
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_GROUP_DIRS)
|
||||
.as_mut()
|
||||
{
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_RADIO_GROUP_DIRS) {
|
||||
let dirs: usize = match cli.get_group_dirs() {
|
||||
Some(GroupDirs::First) => 0,
|
||||
Some(GroupDirs::Last) => 1,
|
||||
None => 2,
|
||||
};
|
||||
let props = props.with_value(PropValue::Unsigned(dirs)).build();
|
||||
let props = RadioPropsBuilder::from(props).with_value(dirs).build();
|
||||
let _ = self.view.update(super::COMPONENT_RADIO_GROUP_DIRS, props);
|
||||
}
|
||||
// File Fmt
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_INPUT_FILE_FMT)
|
||||
.as_mut()
|
||||
{
|
||||
let file_fmt: String = cli.get_file_fmt().unwrap_or_default();
|
||||
let props = props.with_value(PropValue::Str(file_fmt)).build();
|
||||
let _ = self.view.update(super::COMPONENT_INPUT_FILE_FMT, props);
|
||||
// Local File Fmt
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_LOCAL_FILE_FMT) {
|
||||
let file_fmt: String = cli.get_local_file_fmt().unwrap_or_default();
|
||||
let props = InputPropsBuilder::from(props).with_value(file_fmt).build();
|
||||
let _ = self
|
||||
.view
|
||||
.update(super::COMPONENT_INPUT_LOCAL_FILE_FMT, props);
|
||||
}
|
||||
// Remote File Fmt
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_INPUT_REMOTE_FILE_FMT) {
|
||||
let file_fmt: String = cli.get_remote_file_fmt().unwrap_or_default();
|
||||
let props = InputPropsBuilder::from(props).with_value(file_fmt).build();
|
||||
let _ = self
|
||||
.view
|
||||
.update(super::COMPONENT_INPUT_REMOTE_FILE_FMT, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,13 +729,13 @@ impl SetupActivity {
|
||||
/// Collect values from input and put them into the configuration
|
||||
pub(super) fn collect_input_values(&mut self) {
|
||||
if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
if let Some(Payload::Text(editor)) =
|
||||
self.view.get_value(super::COMPONENT_INPUT_TEXT_EDITOR)
|
||||
if let Some(Payload::One(Value::Str(editor))) =
|
||||
self.view.get_state(super::COMPONENT_INPUT_TEXT_EDITOR)
|
||||
{
|
||||
cli.set_text_editor(PathBuf::from(editor.as_str()));
|
||||
}
|
||||
if let Some(Payload::Unsigned(protocol)) =
|
||||
self.view.get_value(super::COMPONENT_RADIO_DEFAULT_PROTOCOL)
|
||||
if let Some(Payload::One(Value::Usize(protocol))) =
|
||||
self.view.get_state(super::COMPONENT_RADIO_DEFAULT_PROTOCOL)
|
||||
{
|
||||
let protocol: FileTransferProtocol = match protocol {
|
||||
1 => FileTransferProtocol::Scp,
|
||||
@@ -733,23 +745,30 @@ impl SetupActivity {
|
||||
};
|
||||
cli.set_default_protocol(protocol);
|
||||
}
|
||||
if let Some(Payload::Unsigned(opt)) =
|
||||
self.view.get_value(super::COMPONENT_RADIO_HIDDEN_FILES)
|
||||
if let Some(Payload::One(Value::Usize(opt))) =
|
||||
self.view.get_state(super::COMPONENT_RADIO_HIDDEN_FILES)
|
||||
{
|
||||
let show: bool = matches!(opt, 0);
|
||||
cli.set_show_hidden_files(show);
|
||||
}
|
||||
if let Some(Payload::Unsigned(opt)) =
|
||||
self.view.get_value(super::COMPONENT_RADIO_UPDATES)
|
||||
if let Some(Payload::One(Value::Usize(opt))) =
|
||||
self.view.get_state(super::COMPONENT_RADIO_UPDATES)
|
||||
{
|
||||
let check: bool = matches!(opt, 0);
|
||||
cli.set_check_for_updates(check);
|
||||
}
|
||||
if let Some(Payload::Text(fmt)) = self.view.get_value(super::COMPONENT_INPUT_FILE_FMT) {
|
||||
cli.set_file_fmt(fmt);
|
||||
if let Some(Payload::One(Value::Str(fmt))) =
|
||||
self.view.get_state(super::COMPONENT_INPUT_LOCAL_FILE_FMT)
|
||||
{
|
||||
cli.set_local_file_fmt(fmt);
|
||||
}
|
||||
if let Some(Payload::Unsigned(opt)) =
|
||||
self.view.get_value(super::COMPONENT_RADIO_GROUP_DIRS)
|
||||
if let Some(Payload::One(Value::Str(fmt))) =
|
||||
self.view.get_state(super::COMPONENT_INPUT_REMOTE_FILE_FMT)
|
||||
{
|
||||
cli.set_remote_file_fmt(fmt);
|
||||
}
|
||||
if let Some(Payload::One(Value::Usize(opt))) =
|
||||
self.view.get_state(super::COMPONENT_RADIO_GROUP_DIRS)
|
||||
{
|
||||
let dirs: Option<GroupDirs> = match opt {
|
||||
0 => Some(GroupDirs::First),
|
||||
@@ -767,17 +786,17 @@ impl SetupActivity {
|
||||
pub(super) fn reload_ssh_keys(&mut self) {
|
||||
if let Some(cli) = self.context.as_ref().unwrap().config_client.as_ref() {
|
||||
// get props
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS).as_mut() {
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS) {
|
||||
// Create texts
|
||||
let keys: Vec<TextSpan> = cli
|
||||
let keys: Vec<String> = cli
|
||||
.iter_ssh_keys()
|
||||
.map(|x| {
|
||||
let (addr, username, _) = cli.get_ssh_key(x).ok().unwrap().unwrap();
|
||||
TextSpan::from(format!("{} at {}", addr, username).as_str())
|
||||
format!("{} at {}", addr, username)
|
||||
})
|
||||
.collect();
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(String::from("SSH Keys")), Some(keys)))
|
||||
let props = BookmarkListPropsBuilder::from(props)
|
||||
.with_bookmarks(Some(String::from("SSH Keys")), keys)
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_LIST_SSH_KEYS, props);
|
||||
}
|
||||
@@ -25,16 +25,106 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
|
||||
// ext
|
||||
use crossterm::event::KeyCode;
|
||||
use tui::{
|
||||
use tuirealm::components::utils::get_block;
|
||||
use tuirealm::event::{Event, KeyCode};
|
||||
use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan};
|
||||
use tuirealm::tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{Block, List, ListItem, ListState},
|
||||
widgets::{BorderType, Borders, List, ListItem, ListState},
|
||||
};
|
||||
use tuirealm::{Canvas, Component, Msg, Payload, Value};
|
||||
|
||||
// -- props
|
||||
|
||||
pub struct BookmarkListPropsBuilder {
|
||||
props: Option<Props>,
|
||||
}
|
||||
|
||||
impl Default for BookmarkListPropsBuilder {
|
||||
fn default() -> Self {
|
||||
BookmarkListPropsBuilder {
|
||||
props: Some(Props::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PropsBuilder for BookmarkListPropsBuilder {
|
||||
fn build(&mut self) -> Props {
|
||||
self.props.take().unwrap()
|
||||
}
|
||||
|
||||
fn hidden(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn visible(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Props> for BookmarkListPropsBuilder {
|
||||
fn from(props: Props) -> Self {
|
||||
BookmarkListPropsBuilder { props: Some(props) }
|
||||
}
|
||||
}
|
||||
|
||||
impl BookmarkListPropsBuilder {
|
||||
/// ### with_foreground
|
||||
///
|
||||
/// Set foreground color for area
|
||||
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.foreground = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_background
|
||||
///
|
||||
/// Set background color for area
|
||||
pub fn with_background(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.background = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_borders
|
||||
///
|
||||
/// Set component borders style
|
||||
pub fn with_borders(
|
||||
&mut self,
|
||||
borders: Borders,
|
||||
variant: BorderType,
|
||||
color: Color,
|
||||
) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.borders = BordersProps {
|
||||
borders,
|
||||
variant,
|
||||
color,
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bookmarks(&mut self, title: Option<String>, bookmarks: Vec<String>) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
let bookmarks: Vec<TextSpan> = bookmarks.into_iter().map(TextSpan::from).collect();
|
||||
props.texts = TextParts::new(title, Some(bookmarks));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// -- states
|
||||
|
||||
@@ -120,7 +210,7 @@ impl BookmarkList {
|
||||
// Initialize states
|
||||
let mut states: OwnStates = OwnStates::default();
|
||||
// Set list length
|
||||
states.set_list_len(match &props.texts.rows {
|
||||
states.set_list_len(match &props.texts.spans {
|
||||
Some(tokens) => tokens.len(),
|
||||
None => 0,
|
||||
});
|
||||
@@ -129,15 +219,11 @@ impl BookmarkList {
|
||||
}
|
||||
|
||||
impl Component for BookmarkList {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
if self.props.visible {
|
||||
// Make list
|
||||
let list_item: Vec<ListItem> = match self.props.texts.rows.as_ref() {
|
||||
let list_item: Vec<ListItem> = match self.props.texts.spans.as_ref() {
|
||||
None => vec![],
|
||||
Some(lines) => lines
|
||||
.iter()
|
||||
@@ -148,30 +234,22 @@ impl Component for BookmarkList {
|
||||
true => (self.props.foreground, self.props.background),
|
||||
false => (Color::Reset, Color::Reset),
|
||||
};
|
||||
let title: String = match self.props.texts.title.as_ref() {
|
||||
Some(t) => t.clone(),
|
||||
None => String::new(),
|
||||
};
|
||||
// Render
|
||||
let mut state: ListState = ListState::default();
|
||||
state.select(Some(self.states.list_index));
|
||||
render.render_stateful_widget(
|
||||
List::new(list_item)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(self.props.borders)
|
||||
.border_style(match self.states.focus {
|
||||
true => Style::default().fg(self.props.background),
|
||||
false => Style::default(),
|
||||
})
|
||||
.title(title),
|
||||
)
|
||||
.block(get_block(
|
||||
&self.props.borders,
|
||||
&self.props.texts.title,
|
||||
self.states.focus,
|
||||
))
|
||||
.start_corner(Corner::TopLeft)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(bg)
|
||||
.fg(fg)
|
||||
.add_modifier(self.props.get_modifiers()),
|
||||
.add_modifier(self.props.modifiers),
|
||||
),
|
||||
area,
|
||||
&mut state,
|
||||
@@ -179,15 +257,10 @@ impl Component for BookmarkList {
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// re-Set list length
|
||||
self.states.set_list_len(match &self.props.texts.rows {
|
||||
self.states.set_list_len(match &self.props.texts.spans {
|
||||
Some(tokens) => tokens.len(),
|
||||
None => 0,
|
||||
});
|
||||
@@ -196,21 +269,13 @@ impl Component for BookmarkList {
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
PropsBuilder::from(self.props.clone())
|
||||
fn get_props(&self) -> Props {
|
||||
self.props.clone()
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
fn on(&mut self, ev: Event) -> Msg {
|
||||
// Match event
|
||||
if let InputEvent::Key(key) = ev {
|
||||
if let Event::Key(key) = ev {
|
||||
match key.code {
|
||||
KeyCode::Down => {
|
||||
// Update states
|
||||
@@ -238,7 +303,7 @@ impl Component for BookmarkList {
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Report event
|
||||
Msg::OnSubmit(self.get_value())
|
||||
Msg::OnSubmit(self.get_state())
|
||||
}
|
||||
_ => {
|
||||
// Return key event to activity
|
||||
@@ -251,25 +316,14 @@ impl Component for BookmarkList {
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Return component value. File list return index
|
||||
fn get_value(&self) -> Payload {
|
||||
Payload::Unsigned(self.states.get_list_index())
|
||||
fn get_state(&self) -> Payload {
|
||||
Payload::One(Value::Usize(self.states.get_list_index()))
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component; basically remove focus
|
||||
fn blur(&mut self) {
|
||||
self.states.focus = false;
|
||||
}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component; basically give focus
|
||||
fn active(&mut self) {
|
||||
self.states.focus = true;
|
||||
}
|
||||
@@ -279,21 +333,34 @@ impl Component for BookmarkList {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::ui::layout::props::{TextParts, TextSpan};
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tuirealm::event::KeyEvent;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_bookmarks_list() {
|
||||
fn test_ui_components_bookmarks_list() {
|
||||
// Make component
|
||||
let mut component: BookmarkList = BookmarkList::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
BookmarkListPropsBuilder::default()
|
||||
.hidden()
|
||||
.visible()
|
||||
.with_foreground(Color::Red)
|
||||
.with_background(Color::Blue)
|
||||
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
|
||||
.with_bookmarks(
|
||||
Some(String::from("filelist")),
|
||||
Some(vec![TextSpan::from("file1"), TextSpan::from("file2")]),
|
||||
))
|
||||
vec![String::from("file1"), String::from("file2")],
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
assert_eq!(component.props.background, Color::Blue);
|
||||
assert_eq!(component.props.visible, true);
|
||||
assert_eq!(
|
||||
component.props.texts.title.as_ref().unwrap().as_str(),
|
||||
"filelist"
|
||||
);
|
||||
assert_eq!(component.props.texts.spans.as_ref().unwrap().len(), 2);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
assert_eq!(component.states.list_len, 2);
|
||||
@@ -304,69 +371,72 @@ mod tests {
|
||||
component.blur();
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
let props = BookmarkListPropsBuilder::from(component.get_props())
|
||||
.with_foreground(Color::Yellow)
|
||||
.hidden()
|
||||
.build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
assert_eq!(component.props.foreground, Color::Yellow);
|
||||
assert_eq!(component.props.visible, false);
|
||||
// Increment list index
|
||||
component.states.list_index += 1;
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Update
|
||||
component.update(
|
||||
component
|
||||
.get_props()
|
||||
.with_texts(TextParts::new(
|
||||
BookmarkListPropsBuilder::from(component.get_props())
|
||||
.with_bookmarks(
|
||||
Some(String::from("filelist")),
|
||||
Some(vec![
|
||||
TextSpan::from("file1"),
|
||||
TextSpan::from("file2"),
|
||||
TextSpan::from("file3"),
|
||||
]),
|
||||
))
|
||||
vec![
|
||||
String::from("file1"),
|
||||
String::from("file2"),
|
||||
String::from("file3"),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
assert_eq!(component.states.list_len, 3);
|
||||
// get value
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(0));
|
||||
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
|
||||
// Render
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Handle inputs
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Down))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Index should be decremented
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Up))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Up))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Index should be 2
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageDown))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::PageDown))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 2);
|
||||
// Index should be 0
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageUp))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::PageUp))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Enter
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
|
||||
Msg::OnSubmit(Payload::Unsigned(0))
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Enter))),
|
||||
Msg::OnSubmit(Payload::One(Value::Usize(0)))
|
||||
);
|
||||
// On key
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
|
||||
);
|
||||
}
|
||||
710
src/ui/components/file_list.rs
Normal file
@@ -0,0 +1,710 @@
|
||||
//! ## FileList
|
||||
//!
|
||||
//! `FileList` component renders a file list tab
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// ext
|
||||
use tuirealm::components::utils::get_block;
|
||||
use tuirealm::event::{Event, KeyCode, KeyModifiers};
|
||||
use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan};
|
||||
use tuirealm::tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{BorderType, Borders, List, ListItem, ListState},
|
||||
};
|
||||
use tuirealm::{Canvas, Component, Msg, Payload, Value};
|
||||
|
||||
// -- props
|
||||
|
||||
pub struct FileListPropsBuilder {
|
||||
props: Option<Props>,
|
||||
}
|
||||
|
||||
impl Default for FileListPropsBuilder {
|
||||
fn default() -> Self {
|
||||
FileListPropsBuilder {
|
||||
props: Some(Props::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PropsBuilder for FileListPropsBuilder {
|
||||
fn build(&mut self) -> Props {
|
||||
self.props.take().unwrap()
|
||||
}
|
||||
|
||||
fn hidden(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn visible(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Props> for FileListPropsBuilder {
|
||||
fn from(props: Props) -> Self {
|
||||
FileListPropsBuilder { props: Some(props) }
|
||||
}
|
||||
}
|
||||
|
||||
impl FileListPropsBuilder {
|
||||
/// ### with_foreground
|
||||
///
|
||||
/// Set foreground color for area
|
||||
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.foreground = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_background
|
||||
///
|
||||
/// Set background color for area
|
||||
pub fn with_background(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.background = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_borders
|
||||
///
|
||||
/// Set component borders style
|
||||
pub fn with_borders(
|
||||
&mut self,
|
||||
borders: Borders,
|
||||
variant: BorderType,
|
||||
color: Color,
|
||||
) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.borders = BordersProps {
|
||||
borders,
|
||||
variant,
|
||||
color,
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_files(&mut self, title: Option<String>, files: Vec<String>) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
let files: Vec<TextSpan> = files.into_iter().map(TextSpan::from).collect();
|
||||
props.texts = TextParts::new(title, Some(files));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// -- states
|
||||
|
||||
/// ## OwnStates
|
||||
///
|
||||
/// OwnStates contains states for this component
|
||||
#[derive(Clone)]
|
||||
struct OwnStates {
|
||||
list_index: usize, // Index of selected element in list
|
||||
selected: Vec<usize>, // Selected files
|
||||
focus: bool, // Has focus?
|
||||
}
|
||||
|
||||
impl Default for OwnStates {
|
||||
fn default() -> Self {
|
||||
OwnStates {
|
||||
list_index: 0,
|
||||
selected: Vec::new(),
|
||||
focus: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnStates {
|
||||
/// ### init_list_states
|
||||
///
|
||||
/// Initialize list states
|
||||
pub fn init_list_states(&mut self, len: usize) {
|
||||
self.selected = Vec::with_capacity(len);
|
||||
self.fix_list_index();
|
||||
}
|
||||
|
||||
/// ### list_index
|
||||
///
|
||||
/// Return current value for list index
|
||||
pub fn list_index(&self) -> usize {
|
||||
self.list_index
|
||||
}
|
||||
|
||||
/// ### incr_list_index
|
||||
///
|
||||
/// Incremenet list index
|
||||
pub fn incr_list_index(&mut self) {
|
||||
// Check if index is at last element
|
||||
if self.list_index + 1 < self.list_len() {
|
||||
self.list_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### decr_list_index
|
||||
///
|
||||
/// Decrement list index
|
||||
pub fn decr_list_index(&mut self) {
|
||||
// Check if index is bigger than 0
|
||||
if self.list_index > 0 {
|
||||
self.list_index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### list_len
|
||||
///
|
||||
/// Returns the length of the file list, which is actually the capacity of the selection vector
|
||||
pub fn list_len(&self) -> usize {
|
||||
self.selected.capacity()
|
||||
}
|
||||
|
||||
/// ### is_selected
|
||||
///
|
||||
/// Returns whether the file with index `entry` is selected
|
||||
pub fn is_selected(&self, entry: usize) -> bool {
|
||||
self.selected.contains(&entry)
|
||||
}
|
||||
|
||||
/// ### is_selection_empty
|
||||
///
|
||||
/// Returns whether the selection is currently empty
|
||||
pub fn is_selection_empty(&self) -> bool {
|
||||
self.selected.is_empty()
|
||||
}
|
||||
|
||||
/// ### get_selection
|
||||
///
|
||||
/// Returns current file selection
|
||||
pub fn get_selection(&self) -> Vec<usize> {
|
||||
self.selected.clone()
|
||||
}
|
||||
|
||||
/// ### fix_list_index
|
||||
///
|
||||
/// Keep index if possible, otherwise set to lenght - 1
|
||||
fn fix_list_index(&mut self) {
|
||||
if self.list_index >= self.list_len() && self.list_len() > 0 {
|
||||
self.list_index = self.list_len() - 1;
|
||||
} else if self.list_len() == 0 {
|
||||
self.list_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// -- select manipulation
|
||||
|
||||
/// ### toggle_file
|
||||
///
|
||||
/// Select or deselect file with provided entry index
|
||||
pub fn toggle_file(&mut self, entry: usize) {
|
||||
match self.is_selected(entry) {
|
||||
true => self.deselect(entry),
|
||||
false => self.select(entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### select_all
|
||||
///
|
||||
/// Select all files
|
||||
pub fn select_all(&mut self) {
|
||||
for i in 0..self.list_len() {
|
||||
self.select(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### select
|
||||
///
|
||||
/// Select provided index if not selected yet
|
||||
fn select(&mut self, entry: usize) {
|
||||
if !self.is_selected(entry) {
|
||||
self.selected.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### deselect
|
||||
///
|
||||
/// Remove element file with associated index
|
||||
fn deselect(&mut self, entry: usize) {
|
||||
if self.is_selected(entry) {
|
||||
self.selected.retain(|&x| x != entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Component
|
||||
|
||||
/// ## FileList
|
||||
///
|
||||
/// File list component
|
||||
pub struct FileList {
|
||||
props: Props,
|
||||
states: OwnStates,
|
||||
}
|
||||
|
||||
impl FileList {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new FileList starting from Props
|
||||
/// The method also initializes the component states.
|
||||
pub fn new(props: Props) -> Self {
|
||||
// Initialize states
|
||||
let mut states: OwnStates = OwnStates::default();
|
||||
// Init list states
|
||||
states.init_list_states(props.texts.spans.as_ref().map(|x| x.len()).unwrap_or(0));
|
||||
FileList { props, states }
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FileList {
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
if self.props.visible {
|
||||
// Make list
|
||||
let list_item: Vec<ListItem> = match self.props.texts.spans.as_ref() {
|
||||
None => vec![],
|
||||
Some(lines) => lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(num, line)| {
|
||||
let to_display: String = match self.states.is_selected(num) {
|
||||
true => format!("*{}", line.content),
|
||||
false => line.content.to_string(),
|
||||
};
|
||||
ListItem::new(Span::from(to_display))
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let (fg, bg): (Color, Color) = match self.states.focus {
|
||||
true => (Color::Black, self.props.background),
|
||||
false => (self.props.foreground, Color::Reset),
|
||||
};
|
||||
// Render
|
||||
let mut state: ListState = ListState::default();
|
||||
state.select(Some(self.states.list_index));
|
||||
render.render_stateful_widget(
|
||||
List::new(list_item)
|
||||
.block(get_block(
|
||||
&self.props.borders,
|
||||
&self.props.texts.title,
|
||||
self.states.focus,
|
||||
))
|
||||
.start_corner(Corner::TopLeft)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(bg)
|
||||
.fg(fg)
|
||||
.add_modifier(self.props.modifiers),
|
||||
),
|
||||
area,
|
||||
&mut state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// re-Set list states
|
||||
self.states.init_list_states(
|
||||
self.props
|
||||
.texts
|
||||
.spans
|
||||
.as_ref()
|
||||
.map(|x| x.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
Msg::None
|
||||
}
|
||||
|
||||
fn get_props(&self) -> Props {
|
||||
self.props.clone()
|
||||
}
|
||||
|
||||
fn on(&mut self, ev: Event) -> Msg {
|
||||
// Match event
|
||||
if let Event::Key(key) = ev {
|
||||
match key.code {
|
||||
KeyCode::Down => {
|
||||
// Update states
|
||||
self.states.incr_list_index();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// Update states
|
||||
self.states.decr_list_index();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
// Update states
|
||||
for _ in 0..8 {
|
||||
self.states.incr_list_index();
|
||||
}
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
// Update states
|
||||
for _ in 0..8 {
|
||||
self.states.decr_list_index();
|
||||
}
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Char('a') => match key.modifiers.intersects(KeyModifiers::CONTROL) {
|
||||
// CTRL+A
|
||||
true => {
|
||||
// Select all
|
||||
self.states.select_all();
|
||||
Msg::None
|
||||
}
|
||||
false => Msg::OnKey(key),
|
||||
},
|
||||
KeyCode::Char('m') => {
|
||||
// Toggle current file in selection
|
||||
self.states.toggle_file(self.states.list_index());
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Report event
|
||||
Msg::OnSubmit(self.get_state())
|
||||
}
|
||||
_ => {
|
||||
// Return key event to activity
|
||||
Msg::OnKey(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unhandled event
|
||||
Msg::None
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_state
|
||||
///
|
||||
/// Get state returns for this component two different payloads based on the states:
|
||||
/// - if the file selection is empty, returns the highlighted item as `One` of `Usize`
|
||||
/// - if at least one item is selected, return the selected as a `Vec` of `Usize`
|
||||
fn get_state(&self) -> Payload {
|
||||
match self.states.is_selection_empty() {
|
||||
true => Payload::One(Value::Usize(self.states.list_index())),
|
||||
false => Payload::Vec(
|
||||
self.states
|
||||
.get_selection()
|
||||
.into_iter()
|
||||
.map(Value::Usize)
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component; basically remove focus
|
||||
fn blur(&mut self) {
|
||||
self.states.focus = false;
|
||||
}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component; basically give focus
|
||||
fn active(&mut self) {
|
||||
self.states.focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use tuirealm::event::KeyEvent;
|
||||
|
||||
#[test]
|
||||
fn test_ui_components_file_list_states() {
|
||||
let mut states: OwnStates = OwnStates::default();
|
||||
assert_eq!(states.list_len(), 0);
|
||||
assert_eq!(states.selected.len(), 0);
|
||||
assert_eq!(states.focus, false);
|
||||
// Init states
|
||||
states.init_list_states(4);
|
||||
assert_eq!(states.list_len(), 4);
|
||||
assert_eq!(states.selected.len(), 0);
|
||||
assert!(states.is_selection_empty());
|
||||
// Select all files
|
||||
states.select_all();
|
||||
assert_eq!(states.list_len(), 4);
|
||||
assert_eq!(states.selected.len(), 4);
|
||||
assert_eq!(states.is_selection_empty(), false);
|
||||
assert_eq!(states.get_selection(), vec![0, 1, 2, 3]);
|
||||
// Verify reset
|
||||
states.init_list_states(5);
|
||||
assert_eq!(states.list_len(), 5);
|
||||
assert_eq!(states.selected.len(), 0);
|
||||
// Toggle file
|
||||
states.toggle_file(2);
|
||||
assert_eq!(states.list_len(), 5);
|
||||
assert_eq!(states.selected.len(), 1);
|
||||
assert_eq!(states.selected[0], 2);
|
||||
states.toggle_file(4);
|
||||
assert_eq!(states.list_len(), 5);
|
||||
assert_eq!(states.selected.len(), 2);
|
||||
assert_eq!(states.selected[1], 4);
|
||||
states.toggle_file(2);
|
||||
assert_eq!(states.list_len(), 5);
|
||||
assert_eq!(states.selected.len(), 1);
|
||||
assert_eq!(states.selected[0], 4);
|
||||
// Select twice (nothing should change)
|
||||
states.select(4);
|
||||
assert_eq!(states.list_len(), 5);
|
||||
assert_eq!(states.selected.len(), 1);
|
||||
assert_eq!(states.selected[0], 4);
|
||||
// Deselect not-selectd item
|
||||
states.deselect(2);
|
||||
assert_eq!(states.list_len(), 5);
|
||||
assert_eq!(states.selected.len(), 1);
|
||||
assert_eq!(states.selected[0], 4);
|
||||
// Index
|
||||
states.init_list_states(2);
|
||||
states.incr_list_index();
|
||||
assert_eq!(states.list_index(), 1);
|
||||
states.incr_list_index();
|
||||
assert_eq!(states.list_index(), 1);
|
||||
states.decr_list_index();
|
||||
assert_eq!(states.list_index(), 0);
|
||||
states.decr_list_index();
|
||||
assert_eq!(states.list_index(), 0);
|
||||
// Try fixing index
|
||||
states.init_list_states(5);
|
||||
states.list_index = 4;
|
||||
states.init_list_states(3);
|
||||
assert_eq!(states.list_index(), 2);
|
||||
states.init_list_states(6);
|
||||
assert_eq!(states.list_index(), 2);
|
||||
// Focus
|
||||
states.focus = true;
|
||||
assert_eq!(states.focus, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_components_file_list() {
|
||||
// Make component
|
||||
let mut component: FileList = FileList::new(
|
||||
FileListPropsBuilder::default()
|
||||
.hidden()
|
||||
.visible()
|
||||
.with_foreground(Color::Red)
|
||||
.with_background(Color::Blue)
|
||||
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
|
||||
.with_files(
|
||||
Some(String::from("files")),
|
||||
vec![String::from("file1"), String::from("file2")],
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
assert_eq!(component.props.background, Color::Blue);
|
||||
assert_eq!(component.props.visible, true);
|
||||
assert_eq!(
|
||||
component.props.texts.title.as_ref().unwrap().as_str(),
|
||||
"files"
|
||||
);
|
||||
assert_eq!(component.props.texts.spans.as_ref().unwrap().len(), 2);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
assert_eq!(component.states.selected.len(), 0);
|
||||
assert_eq!(component.states.list_len(), 2);
|
||||
assert_eq!(component.states.selected.capacity(), 2);
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Focus
|
||||
component.active();
|
||||
assert_eq!(component.states.focus, true);
|
||||
component.blur();
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Update
|
||||
let props = FileListPropsBuilder::from(component.get_props())
|
||||
.with_foreground(Color::Yellow)
|
||||
.hidden()
|
||||
.build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.visible, false);
|
||||
assert_eq!(component.props.foreground, Color::Yellow);
|
||||
// Increment list index
|
||||
component.states.list_index += 1;
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Update
|
||||
component.update(
|
||||
FileListPropsBuilder::from(component.get_props())
|
||||
.with_files(
|
||||
Some(String::from("filelist")),
|
||||
vec![
|
||||
String::from("file1"),
|
||||
String::from("file2"),
|
||||
String::from("file3"),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 1); // Kept
|
||||
assert_eq!(component.states.list_len(), 3);
|
||||
// get value
|
||||
assert_eq!(component.get_state(), Payload::One(Value::Usize(1)));
|
||||
// Render
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Handle inputs
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 2);
|
||||
// Index should be decremented
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Up))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Index should be 2
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::PageDown))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 2);
|
||||
// Index should be 0
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::PageUp))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Enter
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Enter))),
|
||||
Msg::OnSubmit(Payload::One(Value::Usize(0)))
|
||||
);
|
||||
// On key
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
|
||||
);
|
||||
// Verify 'A' still works
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Char('a')))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Char('a')))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_components_file_list_selection() {
|
||||
// Make component
|
||||
let mut component: FileList = FileList::new(
|
||||
FileListPropsBuilder::default()
|
||||
.with_files(
|
||||
Some(String::from("files")),
|
||||
vec![
|
||||
String::from("file1"),
|
||||
String::from("file2"),
|
||||
String::from("file3"),
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
// Get state
|
||||
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
|
||||
// Select one
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
|
||||
Msg::None
|
||||
);
|
||||
// Now should be a vec
|
||||
assert_eq!(component.get_state(), Payload::Vec(vec![Value::Usize(0)]));
|
||||
// De-select
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
|
||||
// Go down
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
|
||||
Msg::None
|
||||
);
|
||||
// Select
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_state(), Payload::Vec(vec![Value::Usize(1)]));
|
||||
// Go down and select
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(
|
||||
component.get_state(),
|
||||
Payload::Vec(vec![Value::Usize(1), Value::Usize(2)])
|
||||
);
|
||||
// Select all
|
||||
assert_eq!(
|
||||
component.on(Event::Key(KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
})),
|
||||
Msg::None
|
||||
);
|
||||
// All selected
|
||||
assert_eq!(
|
||||
component.get_state(),
|
||||
Payload::Vec(vec![Value::Usize(1), Value::Usize(2), Value::Usize(0)])
|
||||
);
|
||||
// Update files
|
||||
component.update(
|
||||
FileListPropsBuilder::from(component.get_props())
|
||||
.with_files(
|
||||
Some(String::from("filelist")),
|
||||
vec![String::from("file1"), String::from("file2")],
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
// Selection should now be empty
|
||||
assert_eq!(component.get_state(), Payload::One(Value::Usize(1)));
|
||||
}
|
||||
}
|
||||
@@ -25,17 +25,84 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
|
||||
// ext
|
||||
use crossterm::event::KeyCode;
|
||||
use std::collections::VecDeque;
|
||||
use tui::{
|
||||
use tuirealm::components::utils::{get_block, wrap_spans};
|
||||
use tuirealm::event::{Event, KeyCode};
|
||||
use tuirealm::props::{BordersProps, Props, PropsBuilder, Table as TextTable, TextParts};
|
||||
use tuirealm::tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::Style,
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, List, ListItem, ListState},
|
||||
style::{Color, Style},
|
||||
widgets::{BorderType, Borders, List, ListItem, ListState},
|
||||
};
|
||||
use tuirealm::{Canvas, Component, Msg, Payload, Value};
|
||||
|
||||
// -- props
|
||||
|
||||
pub struct LogboxPropsBuilder {
|
||||
props: Option<Props>,
|
||||
}
|
||||
|
||||
impl Default for LogboxPropsBuilder {
|
||||
fn default() -> Self {
|
||||
LogboxPropsBuilder {
|
||||
props: Some(Props::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PropsBuilder for LogboxPropsBuilder {
|
||||
fn build(&mut self) -> Props {
|
||||
self.props.take().unwrap()
|
||||
}
|
||||
|
||||
fn hidden(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn visible(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Props> for LogboxPropsBuilder {
|
||||
fn from(props: Props) -> Self {
|
||||
LogboxPropsBuilder { props: Some(props) }
|
||||
}
|
||||
}
|
||||
|
||||
impl LogboxPropsBuilder {
|
||||
/// ### with_borders
|
||||
///
|
||||
/// Set component borders style
|
||||
pub fn with_borders(
|
||||
&mut self,
|
||||
borders: Borders,
|
||||
variant: BorderType,
|
||||
color: Color,
|
||||
) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.borders = BordersProps {
|
||||
borders,
|
||||
variant,
|
||||
color,
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_log(&mut self, title: Option<String>, table: TextTable) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.texts = TextParts::table(title, table);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// -- states
|
||||
|
||||
@@ -132,82 +199,33 @@ impl LogBox {
|
||||
}
|
||||
|
||||
impl Component for LogBox {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
if self.props.visible {
|
||||
let width: usize = area.width as usize - 4;
|
||||
// Make list
|
||||
let list_items: Vec<ListItem> = match self.props.texts.table.as_ref() {
|
||||
None => Vec::new(),
|
||||
Some(table) => table
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut columns: VecDeque<Span> = row
|
||||
.iter()
|
||||
.map(|col| {
|
||||
Span::styled(
|
||||
col.content.clone(),
|
||||
Style::default()
|
||||
.add_modifier(col.get_modifiers())
|
||||
.fg(col.fg)
|
||||
.bg(col.bg),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Let's convert column spans into Spans rows NOTE: -4 because first line is always made by 5 columns; but there's always 1
|
||||
let mut rows: Vec<Spans> = Vec::with_capacity(columns.len() - 4);
|
||||
// Get first row
|
||||
let mut first_row: Vec<Span> = Vec::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
if let Some(col) = columns.pop_front() {
|
||||
first_row.push(col);
|
||||
}
|
||||
}
|
||||
rows.push(Spans::from(first_row));
|
||||
// Fill remaining rows
|
||||
let cycles: usize = columns.len();
|
||||
for _ in 0..cycles {
|
||||
if let Some(col) = columns.pop_front() {
|
||||
rows.push(Spans::from(vec![col]));
|
||||
}
|
||||
}
|
||||
ListItem::new(rows)
|
||||
})
|
||||
.map(|row| ListItem::new(wrap_spans(row, width, &self.props)))
|
||||
.collect(), // Make List item from TextSpan
|
||||
};
|
||||
let title: String = match self.props.texts.title.as_ref() {
|
||||
Some(t) => t.clone(),
|
||||
None => String::new(),
|
||||
};
|
||||
// Render
|
||||
|
||||
let w = List::new(list_items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(self.props.borders)
|
||||
.border_style(match self.states.focus {
|
||||
true => Style::default().fg(self.props.foreground),
|
||||
false => Style::default(),
|
||||
})
|
||||
.title(title),
|
||||
)
|
||||
.block(get_block(
|
||||
&self.props.borders,
|
||||
&self.props.texts.title,
|
||||
self.states.focus,
|
||||
))
|
||||
.start_corner(Corner::BottomLeft)
|
||||
.highlight_symbol(">> ")
|
||||
.highlight_style(Style::default().add_modifier(self.props.get_modifiers()));
|
||||
.highlight_style(Style::default().add_modifier(self.props.modifiers));
|
||||
let mut state: ListState = ListState::default();
|
||||
state.select(Some(self.states.list_index));
|
||||
render.render_stateful_widget(w, area, &mut state);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// re-Set list length
|
||||
@@ -220,21 +238,13 @@ impl Component for LogBox {
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
PropsBuilder::from(self.props.clone())
|
||||
fn get_props(&self) -> Props {
|
||||
self.props.clone()
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
fn on(&mut self, ev: Event) -> Msg {
|
||||
// Match event
|
||||
if let InputEvent::Key(key) = ev {
|
||||
if let Event::Key(key) = ev {
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
// Update states
|
||||
@@ -271,25 +281,14 @@ impl Component for LogBox {
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Return component value. File list return index
|
||||
fn get_value(&self) -> Payload {
|
||||
Payload::Unsigned(self.states.get_list_index())
|
||||
fn get_state(&self) -> Payload {
|
||||
Payload::One(Value::Usize(self.states.get_list_index()))
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component; basically remove focus
|
||||
fn blur(&mut self) {
|
||||
self.states.focus = false;
|
||||
}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component; basically give focus
|
||||
fn active(&mut self) {
|
||||
self.states.focus = true;
|
||||
}
|
||||
@@ -299,16 +298,20 @@ impl Component for LogBox {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::ui::layout::props::{TableBuilder, TextParts, TextSpan};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use tui::style::Color;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tuirealm::event::{KeyCode, KeyEvent};
|
||||
use tuirealm::props::{TableBuilder, TextSpan};
|
||||
use tuirealm::tui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_logbox() {
|
||||
fn test_ui_components_logbox() {
|
||||
let mut component: LogBox = LogBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
LogboxPropsBuilder::default()
|
||||
.hidden()
|
||||
.visible()
|
||||
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
|
||||
.with_log(
|
||||
Some(String::from("Log")),
|
||||
TableBuilder::default()
|
||||
.add_col(TextSpan::from("12:29"))
|
||||
@@ -317,9 +320,15 @@ mod tests {
|
||||
.add_col(TextSpan::from("12:38"))
|
||||
.add_col(TextSpan::from("system alive"))
|
||||
.build(),
|
||||
))
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
assert_eq!(component.props.visible, true);
|
||||
assert_eq!(
|
||||
component.props.texts.title.as_ref().unwrap().as_str(),
|
||||
"Log"
|
||||
);
|
||||
assert_eq!(component.props.texts.table.as_ref().unwrap().len(), 2);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
assert_eq!(component.states.list_len, 2);
|
||||
@@ -330,17 +339,18 @@ mod tests {
|
||||
component.blur();
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
let props = LogboxPropsBuilder::from(component.get_props())
|
||||
.hidden()
|
||||
.build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
assert_eq!(component.props.visible, false);
|
||||
// Increment list index
|
||||
component.states.list_index += 1;
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Update
|
||||
component.update(
|
||||
component
|
||||
.get_props()
|
||||
.with_texts(TextParts::table(
|
||||
LogboxPropsBuilder::from(component.get_props())
|
||||
.with_log(
|
||||
Some(String::from("Log")),
|
||||
TableBuilder::default()
|
||||
.add_col(TextSpan::from("12:29"))
|
||||
@@ -352,49 +362,49 @@ mod tests {
|
||||
.add_col(TextSpan::from("12:41"))
|
||||
.add_col(TextSpan::from("system is going down for REBOOT"))
|
||||
.build(),
|
||||
))
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 0); // Last item
|
||||
assert_eq!(component.states.list_len, 3);
|
||||
// get value
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(0));
|
||||
assert_eq!(component.get_state(), Payload::One(Value::Usize(0)));
|
||||
// RenderData
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Set cursor to 0
|
||||
component.states.list_index = 0;
|
||||
// Handle inputs
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Up))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Up))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Index should be decremented
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Down))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Down))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Index should be 2
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageUp))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::PageUp))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 2);
|
||||
// Index should be 0
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageDown))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::PageDown))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// On key
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -27,16 +27,99 @@
|
||||
*/
|
||||
// deps
|
||||
extern crate textwrap;
|
||||
extern crate tuirealm;
|
||||
// locals
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
|
||||
use crate::utils::fmt::align_text_center;
|
||||
// ext
|
||||
use tui::{
|
||||
use tuirealm::components::utils::{get_block, use_or_default_styles};
|
||||
use tuirealm::event::Event;
|
||||
use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan};
|
||||
use tuirealm::tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::{Color, Style},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, BorderType, List, ListItem},
|
||||
widgets::{BorderType, Borders, List, ListItem},
|
||||
};
|
||||
use tuirealm::{Canvas, Component, Msg, Payload};
|
||||
|
||||
// -- Props
|
||||
|
||||
pub struct MsgBoxPropsBuilder {
|
||||
props: Option<Props>,
|
||||
}
|
||||
|
||||
impl Default for MsgBoxPropsBuilder {
|
||||
fn default() -> Self {
|
||||
MsgBoxPropsBuilder {
|
||||
props: Some(Props::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PropsBuilder for MsgBoxPropsBuilder {
|
||||
fn build(&mut self) -> Props {
|
||||
self.props.take().unwrap()
|
||||
}
|
||||
|
||||
fn hidden(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn visible(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Props> for MsgBoxPropsBuilder {
|
||||
fn from(props: Props) -> Self {
|
||||
MsgBoxPropsBuilder { props: Some(props) }
|
||||
}
|
||||
}
|
||||
|
||||
impl MsgBoxPropsBuilder {
|
||||
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.foreground = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn bold(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.modifiers |= Modifier::BOLD;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_borders(
|
||||
&mut self,
|
||||
borders: Borders,
|
||||
variant: BorderType,
|
||||
color: Color,
|
||||
) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.borders = BordersProps {
|
||||
borders,
|
||||
variant,
|
||||
color,
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_texts(&mut self, title: Option<String>, texts: Vec<TextSpan>) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.texts = TextParts::new(title, Some(texts));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// -- component
|
||||
|
||||
@@ -54,56 +137,36 @@ impl MsgBox {
|
||||
}
|
||||
|
||||
impl Component for MsgBox {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
// Make a Span
|
||||
if self.props.visible {
|
||||
let lines: Vec<ListItem> = match self.props.texts.rows.as_ref() {
|
||||
let lines: Vec<ListItem> = match self.props.texts.spans.as_ref() {
|
||||
None => Vec::new(),
|
||||
Some(rows) => {
|
||||
let mut lines: Vec<ListItem> = Vec::new();
|
||||
for line in rows.iter() {
|
||||
// Keep line color, or use default
|
||||
let line_fg: Color = match line.fg {
|
||||
Color::Reset => self.props.foreground,
|
||||
_ => line.fg,
|
||||
};
|
||||
let line_bg: Color = match line.bg {
|
||||
Color::Reset => self.props.background,
|
||||
_ => line.bg,
|
||||
};
|
||||
let (fg, bg, modifiers) = use_or_default_styles(&self.props, line);
|
||||
let message_row =
|
||||
textwrap::wrap(line.content.as_str(), area.width as usize);
|
||||
for msg in message_row.iter() {
|
||||
lines.push(ListItem::new(Spans::from(vec![Span::styled(
|
||||
align_text_center(msg, area.width),
|
||||
Style::default()
|
||||
.add_modifier(line.get_modifiers())
|
||||
.fg(line_fg)
|
||||
.bg(line_bg),
|
||||
Style::default().add_modifier(modifiers).fg(fg).bg(bg),
|
||||
)])));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
};
|
||||
let title: String = match self.props.texts.title.as_ref() {
|
||||
Some(t) => t.clone(),
|
||||
None => String::new(),
|
||||
};
|
||||
render.render_widget(
|
||||
List::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(self.props.borders)
|
||||
.border_style(Style::default().fg(self.props.foreground))
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(title),
|
||||
)
|
||||
.block(get_block(
|
||||
&self.props.borders,
|
||||
&self.props.texts.title,
|
||||
true,
|
||||
))
|
||||
.start_corner(Corner::TopLeft)
|
||||
.style(
|
||||
Style::default()
|
||||
@@ -115,59 +178,31 @@ impl Component for MsgBox {
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update.
|
||||
/// Returns a Msg to the view
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// Return None
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
PropsBuilder::from(self.props.clone())
|
||||
fn get_props(&self) -> Props {
|
||||
self.props.clone()
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states.
|
||||
/// Returns a Msg to the view.
|
||||
/// Returns always None, since cannot have any focus
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
fn on(&mut self, ev: Event) -> Msg {
|
||||
// Return key
|
||||
if let InputEvent::Key(key) = ev {
|
||||
if let Event::Key(key) = ev {
|
||||
Msg::OnKey(key)
|
||||
} else {
|
||||
Msg::None
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get current value from component
|
||||
/// For this component returns always None
|
||||
fn get_value(&self) -> Payload {
|
||||
fn get_state(&self) -> Payload {
|
||||
Payload::None
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component
|
||||
fn blur(&mut self) {}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component
|
||||
fn active(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -175,39 +210,53 @@ impl Component for MsgBox {
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::ui::layout::props::{TextParts, TextSpan, TextSpanBuilder};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use tui::style::Color;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tuirealm::event::{KeyCode, KeyEvent};
|
||||
use tuirealm::props::{TextSpan, TextSpanBuilder};
|
||||
use tuirealm::tui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_msgbox() {
|
||||
fn test_ui_components_msgbox() {
|
||||
let mut component: MsgBox = MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
MsgBoxPropsBuilder::default()
|
||||
.hidden()
|
||||
.visible()
|
||||
.with_foreground(Color::Red)
|
||||
.bold()
|
||||
.with_borders(Borders::ALL, BorderType::Double, Color::Red)
|
||||
.with_texts(
|
||||
None,
|
||||
Some(vec![
|
||||
vec![
|
||||
TextSpan::from("Press "),
|
||||
TextSpanBuilder::new("<ESC>")
|
||||
.with_foreground(Color::Cyan)
|
||||
.bold()
|
||||
.build(),
|
||||
TextSpan::from(" to quit"),
|
||||
]),
|
||||
))
|
||||
],
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
assert!(component.props.modifiers.intersects(Modifier::BOLD));
|
||||
assert_eq!(component.props.visible, true);
|
||||
assert_eq!(component.props.texts.spans.as_ref().unwrap().len(), 3);
|
||||
component.active();
|
||||
component.blur();
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
let props = MsgBoxPropsBuilder::from(component.get_props())
|
||||
.hidden()
|
||||
.with_foreground(Color::Yellow)
|
||||
.build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
assert_eq!(component.props.visible, false);
|
||||
assert_eq!(component.props.foreground, Color::Yellow);
|
||||
// Get value
|
||||
assert_eq!(component.get_value(), Payload::None);
|
||||
assert_eq!(component.get_state(), Payload::None);
|
||||
// Event
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
|
||||
component.on(Event::Key(KeyEvent::from(KeyCode::Delete))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
|
||||
);
|
||||
}
|
||||
@@ -27,13 +27,12 @@
|
||||
*/
|
||||
// Dependencies
|
||||
extern crate crossterm;
|
||||
extern crate tui;
|
||||
extern crate tuirealm;
|
||||
|
||||
// Locals
|
||||
use super::input::InputHandler;
|
||||
use super::store::Store;
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::host::Localhost;
|
||||
use crate::system::config_client::ConfigClient;
|
||||
|
||||
// Includes
|
||||
@@ -42,14 +41,13 @@ use crossterm::execute;
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use std::io::{stdout, Stdout};
|
||||
use std::path::PathBuf;
|
||||
use tui::backend::CrosstermBackend;
|
||||
use tui::Terminal;
|
||||
use tuirealm::tui::backend::CrosstermBackend;
|
||||
use tuirealm::tui::Terminal;
|
||||
|
||||
/// ## Context
|
||||
///
|
||||
/// Context holds data structures used by the ui
|
||||
pub struct Context {
|
||||
pub local: Localhost,
|
||||
pub ft_params: Option<FileTransferParams>,
|
||||
pub(crate) config_client: Option<ConfigClient>,
|
||||
pub(crate) store: Store,
|
||||
@@ -74,16 +72,11 @@ impl Context {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new Context
|
||||
pub fn new(
|
||||
local: Localhost,
|
||||
config_client: Option<ConfigClient>,
|
||||
error: Option<String>,
|
||||
) -> Context {
|
||||
pub fn new(config_client: Option<ConfigClient>, error: Option<String>) -> Context {
|
||||
// Create terminal
|
||||
let mut stdout = stdout();
|
||||
assert!(execute!(stdout, EnterAlternateScreen).is_ok());
|
||||
Context {
|
||||
local,
|
||||
ft_params: None,
|
||||
config_client,
|
||||
store: Store::init(),
|
||||
@@ -93,14 +86,12 @@ impl Context {
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: in case is necessary
|
||||
/// ### set_error
|
||||
///
|
||||
/// Set context error
|
||||
pub fn set_error(&mut self, err: String) {
|
||||
self.error = Some(err);
|
||||
}
|
||||
*/
|
||||
|
||||
/// ### get_error
|
||||
///
|
||||
@@ -113,40 +104,45 @@ impl Context {
|
||||
///
|
||||
/// Enter alternate screen (gui window)
|
||||
pub fn enter_alternate_screen(&mut self) {
|
||||
let _ = execute!(
|
||||
match execute!(
|
||||
self.terminal.backend_mut(),
|
||||
EnterAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
) {
|
||||
Err(err) => error!("Failed to enter alternate screen: {}", err),
|
||||
Ok(_) => info!("Entered alternate screen"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### leave_alternate_screen
|
||||
///
|
||||
/// Go back to normal screen (gui window)
|
||||
pub fn leave_alternate_screen(&mut self) {
|
||||
let _ = execute!(
|
||||
match execute!(
|
||||
self.terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
) {
|
||||
Err(err) => error!("Failed to leave alternate screen: {}", err),
|
||||
Ok(_) => info!("Left alternate screen"),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### clear_screen
|
||||
///
|
||||
/// Clear terminal screen
|
||||
pub fn clear_screen(&mut self) {
|
||||
let _ = self.terminal.clear();
|
||||
match self.terminal.clear() {
|
||||
Err(err) => error!("Failed to clear screen: {}", err),
|
||||
Ok(_) => info!("Cleared screen"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Context {
|
||||
fn drop(&mut self) {
|
||||
// Re-enable terminal stuff
|
||||
let _ = execute!(
|
||||
self.terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
self.leave_alternate_screen();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +164,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_ui_context_ft_params() {
|
||||
let params: FileTransferParams = FileTransferParams::default();
|
||||
@@ -178,31 +176,23 @@ mod tests {
|
||||
assert!(params.password.is_none());
|
||||
}
|
||||
|
||||
//use crate::filetransfer::sftp_transfer::SftpFileTransfer;
|
||||
//use std::path::PathBuf;
|
||||
|
||||
/*
|
||||
#[test]
|
||||
fn test_ui_context_new() {
|
||||
#[cfg(not(feature = "githubActions"))]
|
||||
fn test_ui_context() {
|
||||
// Prepare stuff
|
||||
Context::new(
|
||||
build_sftp_client(),
|
||||
Localhost::new(PathBuf::from("/")).ok().unwrap(),
|
||||
);
|
||||
let mut ctx: Context = Context::new(None, Some(String::from("alles kaput")));
|
||||
assert!(ctx.error.is_some());
|
||||
assert_eq!(ctx.get_error().unwrap().as_str(), "alles kaput");
|
||||
assert!(ctx.error.is_none());
|
||||
assert!(ctx.get_error().is_none());
|
||||
ctx.set_error(String::from("err"));
|
||||
assert!(ctx.error.is_some());
|
||||
assert!(ctx.get_error().is_some());
|
||||
assert!(ctx.get_error().is_none());
|
||||
// Try other methods
|
||||
ctx.enter_alternate_screen();
|
||||
ctx.clear_screen();
|
||||
ctx.leave_alternate_screen();
|
||||
drop(ctx);
|
||||
}
|
||||
|
||||
fn build_sftp_client() -> Box<dyn FileTransfer> {
|
||||
let mut sftp_client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
// Connect to remote
|
||||
assert!(sftp_client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
22,
|
||||
Some(String::from("demo")),
|
||||
Some(String::from("password"))
|
||||
)
|
||||
.is_ok());
|
||||
Box::new(sftp_client)
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -1,377 +0,0 @@
|
||||
//! ## FileList
|
||||
//!
|
||||
//! `FileList` component renders a file list tab
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
|
||||
// ext
|
||||
use crossterm::event::KeyCode;
|
||||
use tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::{Color, Style},
|
||||
text::Span,
|
||||
widgets::{Block, List, ListItem, ListState},
|
||||
};
|
||||
|
||||
// -- states
|
||||
|
||||
/// ## OwnStates
|
||||
///
|
||||
/// OwnStates contains states for this component
|
||||
#[derive(Clone)]
|
||||
struct OwnStates {
|
||||
list_index: usize, // Index of selected element in list
|
||||
list_len: usize, // Length of file list
|
||||
focus: bool, // Has focus?
|
||||
}
|
||||
|
||||
impl Default for OwnStates {
|
||||
fn default() -> Self {
|
||||
OwnStates {
|
||||
list_index: 0,
|
||||
list_len: 0,
|
||||
focus: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnStates {
|
||||
/// ### set_list_len
|
||||
///
|
||||
/// Set list length
|
||||
pub fn set_list_len(&mut self, len: usize) {
|
||||
self.list_len = len;
|
||||
}
|
||||
|
||||
/// ### get_list_index
|
||||
///
|
||||
/// Return current value for list index
|
||||
pub fn get_list_index(&self) -> usize {
|
||||
self.list_index
|
||||
}
|
||||
|
||||
/// ### incr_list_index
|
||||
///
|
||||
/// Incremenet list index
|
||||
pub fn incr_list_index(&mut self) {
|
||||
// Check if index is at last element
|
||||
if self.list_index + 1 < self.list_len {
|
||||
self.list_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### decr_list_index
|
||||
///
|
||||
/// Decrement list index
|
||||
pub fn decr_list_index(&mut self) {
|
||||
// Check if index is bigger than 0
|
||||
if self.list_index > 0 {
|
||||
self.list_index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### fix_list_index
|
||||
///
|
||||
/// Keep index if possible, otherwise set to lenght - 1
|
||||
pub fn fix_list_index(&mut self) {
|
||||
if self.list_index >= self.list_len && self.list_len > 0 {
|
||||
self.list_index = self.list_len - 1;
|
||||
} else if self.list_len == 0 {
|
||||
self.list_index = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Component
|
||||
|
||||
/// ## FileList
|
||||
///
|
||||
/// File list component
|
||||
pub struct FileList {
|
||||
props: Props,
|
||||
states: OwnStates,
|
||||
}
|
||||
|
||||
impl FileList {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new FileList starting from Props
|
||||
/// The method also initializes the component states.
|
||||
pub fn new(props: Props) -> Self {
|
||||
// Initialize states
|
||||
let mut states: OwnStates = OwnStates::default();
|
||||
// Set list length
|
||||
states.set_list_len(match &props.texts.rows {
|
||||
Some(tokens) => tokens.len(),
|
||||
None => 0,
|
||||
});
|
||||
FileList { props, states }
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for FileList {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
if self.props.visible {
|
||||
// Make list
|
||||
let list_item: Vec<ListItem> = match self.props.texts.rows.as_ref() {
|
||||
None => vec![],
|
||||
Some(lines) => lines
|
||||
.iter()
|
||||
.map(|line| ListItem::new(Span::from(line.content.to_string())))
|
||||
.collect(),
|
||||
};
|
||||
let (fg, bg): (Color, Color) = match self.states.focus {
|
||||
true => (Color::Black, self.props.background),
|
||||
false => (self.props.foreground, Color::Reset),
|
||||
};
|
||||
let title: String = match self.props.texts.title.as_ref() {
|
||||
Some(t) => t.clone(),
|
||||
None => String::new(),
|
||||
};
|
||||
// Render
|
||||
let mut state: ListState = ListState::default();
|
||||
state.select(Some(self.states.list_index));
|
||||
render.render_stateful_widget(
|
||||
List::new(list_item)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(self.props.borders)
|
||||
.border_style(match self.states.focus {
|
||||
true => Style::default().fg(self.props.foreground),
|
||||
false => Style::default(),
|
||||
})
|
||||
.title(title),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.bg(bg)
|
||||
.fg(fg)
|
||||
.add_modifier(self.props.get_modifiers()),
|
||||
),
|
||||
area,
|
||||
&mut state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// re-Set list length
|
||||
self.states.set_list_len(match &self.props.texts.rows {
|
||||
Some(tokens) => tokens.len(),
|
||||
None => 0,
|
||||
});
|
||||
// Fix list index
|
||||
self.states.fix_list_index();
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
PropsBuilder::from(self.props.clone())
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
// Match event
|
||||
if let InputEvent::Key(key) = ev {
|
||||
match key.code {
|
||||
KeyCode::Down => {
|
||||
// Update states
|
||||
self.states.incr_list_index();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// Update states
|
||||
self.states.decr_list_index();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
// Update states
|
||||
for _ in 0..8 {
|
||||
self.states.incr_list_index();
|
||||
}
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
// Update states
|
||||
for _ in 0..8 {
|
||||
self.states.decr_list_index();
|
||||
}
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Report event
|
||||
Msg::OnSubmit(self.get_value())
|
||||
}
|
||||
_ => {
|
||||
// Return key event to activity
|
||||
Msg::OnKey(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unhandled event
|
||||
Msg::None
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Return component value. File list return index
|
||||
fn get_value(&self) -> Payload {
|
||||
Payload::Unsigned(self.states.get_list_index())
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component; basically remove focus
|
||||
fn blur(&mut self) {
|
||||
self.states.focus = false;
|
||||
}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component; basically give focus
|
||||
fn active(&mut self) {
|
||||
self.states.focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::ui::layout::props::{TextParts, TextSpan};
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_file_list() {
|
||||
// Make component
|
||||
let mut component: FileList = FileList::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("filelist")),
|
||||
Some(vec![TextSpan::from("file1"), TextSpan::from("file2")]),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
assert_eq!(component.states.list_len, 2);
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Focus
|
||||
component.active();
|
||||
assert_eq!(component.states.focus, true);
|
||||
component.blur();
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
// Increment list index
|
||||
component.states.list_index += 1;
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Update
|
||||
component.update(
|
||||
component
|
||||
.get_props()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("filelist")),
|
||||
Some(vec![
|
||||
TextSpan::from("file1"),
|
||||
TextSpan::from("file2"),
|
||||
TextSpan::from("file3"),
|
||||
]),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
// Verify states
|
||||
assert_eq!(component.states.list_index, 1); // Kept
|
||||
assert_eq!(component.states.list_len, 3);
|
||||
// get value
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(1));
|
||||
// Render
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Handle inputs
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Down))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 2);
|
||||
// Index should be decremented
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Up))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 1);
|
||||
// Index should be 2
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageDown))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 2);
|
||||
// Index should be 0
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::PageUp))),
|
||||
Msg::None
|
||||
);
|
||||
// Index should be incremented
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Enter
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
|
||||
Msg::OnSubmit(Payload::Unsigned(0))
|
||||
);
|
||||
// On key
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
//! ## Input
|
||||
//!
|
||||
//! `Input` component renders an input box
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::super::props::InputType;
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, PropValue, Props, PropsBuilder};
|
||||
// ext
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use tui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
widgets::{Block, BorderType, Paragraph},
|
||||
};
|
||||
|
||||
// -- states
|
||||
|
||||
/// ## OwnStates
|
||||
///
|
||||
/// OwnStates contains states for this component
|
||||
#[derive(Clone)]
|
||||
struct OwnStates {
|
||||
input: Vec<char>, // Current input
|
||||
cursor: usize, // Input position
|
||||
focus: bool, // Focus
|
||||
}
|
||||
|
||||
impl Default for OwnStates {
|
||||
fn default() -> Self {
|
||||
OwnStates {
|
||||
input: Vec::new(),
|
||||
cursor: 0,
|
||||
focus: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnStates {
|
||||
/// ### append
|
||||
///
|
||||
/// Append, if possible according to input type, the character to the input vec
|
||||
pub fn append(&mut self, ch: char, itype: InputType, max_len: Option<usize>) {
|
||||
// Check if max length has been reached
|
||||
if self.input.len() < max_len.unwrap_or(usize::MAX) {
|
||||
match itype {
|
||||
InputType::Number => {
|
||||
if ch.is_digit(10) {
|
||||
// Must be digit
|
||||
self.input.insert(self.cursor, ch);
|
||||
// Increment cursor
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// No rule
|
||||
self.input.insert(self.cursor, ch);
|
||||
// Increment cursor
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### backspace
|
||||
///
|
||||
/// Delete element at cursor -1; then decrement cursor by 1
|
||||
pub fn backspace(&mut self) {
|
||||
if self.cursor > 0 && !self.input.is_empty() {
|
||||
self.input.remove(self.cursor - 1);
|
||||
// Decrement cursor
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### delete
|
||||
///
|
||||
/// Delete element at cursor
|
||||
pub fn delete(&mut self) {
|
||||
if self.cursor < self.input.len() {
|
||||
self.input.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### incr_cursor
|
||||
///
|
||||
/// Increment cursor value by one if possible
|
||||
pub fn incr_cursor(&mut self) {
|
||||
if self.cursor < self.input.len() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### cursoro_at_begin
|
||||
///
|
||||
/// Place cursor at the begin of the input
|
||||
pub fn cursor_at_begin(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
/// ### cursor_at_end
|
||||
///
|
||||
/// Place cursor at the end of the input
|
||||
pub fn cursor_at_end(&mut self) {
|
||||
self.cursor = self.input.len();
|
||||
}
|
||||
|
||||
/// ### decr_cursor
|
||||
///
|
||||
/// Decrement cursor value by one if possible
|
||||
pub fn decr_cursor(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### render_value
|
||||
///
|
||||
/// Get value as string to render
|
||||
pub fn render_value(&self, itype: InputType) -> String {
|
||||
match itype {
|
||||
InputType::Password => (0..self.input.len()).map(|_| '*').collect(),
|
||||
_ => self.get_value(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get value as string
|
||||
pub fn get_value(&self) -> String {
|
||||
self.input.iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Component
|
||||
|
||||
/// ## FileList
|
||||
///
|
||||
/// File list component
|
||||
pub struct Input {
|
||||
props: Props,
|
||||
states: OwnStates,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new Input starting from Props
|
||||
/// The method also initializes the component states.
|
||||
pub fn new(props: Props) -> Self {
|
||||
// Initialize states
|
||||
let mut states: OwnStates = OwnStates::default();
|
||||
// Set state value from props
|
||||
if let PropValue::Str(val) = props.value.clone() {
|
||||
for ch in val.chars() {
|
||||
states.append(ch, props.input_type, props.input_len);
|
||||
}
|
||||
}
|
||||
Input { props, states }
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Input {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
if self.props.visible {
|
||||
let title: String = match self.props.texts.title.as_ref() {
|
||||
Some(t) => t.clone(),
|
||||
None => String::new(),
|
||||
};
|
||||
let p: Paragraph = Paragraph::new(self.states.render_value(self.props.input_type))
|
||||
.style(match self.states.focus {
|
||||
true => Style::default().fg(self.props.foreground),
|
||||
false => Style::default(),
|
||||
})
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(self.props.borders)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(title),
|
||||
);
|
||||
render.render_widget(p, area);
|
||||
// Set cursor, if focus
|
||||
if self.states.focus {
|
||||
let x: u16 = area.x + (self.states.cursor as u16) + 1;
|
||||
render.set_cursor(x, area.y + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update.
|
||||
/// Returns a Msg to the view
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// Set value from props
|
||||
if let PropValue::Str(val) = self.props.value.clone() {
|
||||
self.states.input = Vec::new();
|
||||
self.states.cursor = 0;
|
||||
for ch in val.chars() {
|
||||
self.states
|
||||
.append(ch, self.props.input_type, self.props.input_len);
|
||||
}
|
||||
}
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
// Make properties with value from states
|
||||
let mut props: Props = self.props.clone();
|
||||
props.value = PropValue::Str(self.states.get_value());
|
||||
PropsBuilder::from(props)
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states.
|
||||
/// Returns a Msg to the view
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
if let InputEvent::Key(key) = ev {
|
||||
match key.code {
|
||||
KeyCode::Backspace => {
|
||||
// Backspace and None
|
||||
self.states.backspace();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
// Delete and None
|
||||
self.states.delete();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Enter => Msg::OnSubmit(self.get_value()),
|
||||
KeyCode::Left => {
|
||||
// Move cursor left; msg None
|
||||
self.states.decr_cursor();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
// Move cursor right; Msg None
|
||||
self.states.incr_cursor();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::End => {
|
||||
// Cursor at last position
|
||||
self.states.cursor_at_end();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Home => {
|
||||
// Cursor at first positon
|
||||
self.states.cursor_at_begin();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Char(ch) => {
|
||||
// Check if modifiers is NOT CTRL OR ALT
|
||||
if !key.modifiers.intersects(KeyModifiers::CONTROL)
|
||||
&& !key.modifiers.intersects(KeyModifiers::ALT)
|
||||
{
|
||||
// Push char to input
|
||||
self.states
|
||||
.append(ch, self.props.input_type, self.props.input_len);
|
||||
// Message none
|
||||
Msg::None
|
||||
} else {
|
||||
// Return key
|
||||
Msg::OnKey(key)
|
||||
}
|
||||
}
|
||||
_ => Msg::OnKey(key),
|
||||
}
|
||||
} else {
|
||||
Msg::None
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get current value from component
|
||||
/// Returns the value as string or as a number based on the input value
|
||||
fn get_value(&self) -> Payload {
|
||||
match self.props.input_type {
|
||||
InputType::Number => {
|
||||
Payload::Unsigned(self.states.get_value().parse::<usize>().ok().unwrap_or(0))
|
||||
}
|
||||
_ => Payload::Text(self.states.get_value()),
|
||||
}
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component; basically remove focus
|
||||
fn blur(&mut self) {
|
||||
self.states.focus = false;
|
||||
}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component; basically give focus
|
||||
fn active(&mut self) {
|
||||
self.states.focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use tui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_input_text() {
|
||||
// Instantiate Input with value
|
||||
let mut component: Input = Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_input(InputType::Text)
|
||||
.with_input_len(5)
|
||||
.with_value(PropValue::Str(String::from("home")))
|
||||
.build(),
|
||||
);
|
||||
// Verify initial state
|
||||
assert_eq!(component.states.cursor, 4);
|
||||
assert_eq!(component.states.input.len(), 4);
|
||||
// Focus
|
||||
assert_eq!(component.states.focus, false);
|
||||
component.active();
|
||||
assert_eq!(component.states.focus, true);
|
||||
component.blur();
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
// Get value
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("home")));
|
||||
// RenderData
|
||||
//assert_eq!(component.render().unwrap().cursor, 4);
|
||||
assert_eq!(component.states.cursor, 4);
|
||||
// Handle events
|
||||
// Try key with ctrl
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::new(
|
||||
KeyCode::Char('a'),
|
||||
KeyModifiers::CONTROL
|
||||
))),
|
||||
Msg::OnKey(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)),
|
||||
);
|
||||
// String shouldn't have changed
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("home")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 4);
|
||||
assert_eq!(component.states.cursor, 4);
|
||||
// Character
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('/')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("home/")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 5);
|
||||
assert_eq!(component.states.cursor, 5);
|
||||
// Verify max length (shouldn't push any character)
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("home/")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 5);
|
||||
assert_eq!(component.states.cursor, 5);
|
||||
// Enter
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
|
||||
Msg::OnSubmit(Payload::Text(String::from("home/")))
|
||||
);
|
||||
// Backspace
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("home")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 4);
|
||||
assert_eq!(component.states.cursor, 4);
|
||||
// Check backspace at 0
|
||||
component.states.input = vec!['h'];
|
||||
component.states.cursor = 1;
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 0);
|
||||
assert_eq!(component.states.cursor, 0);
|
||||
// Another one...
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Backspace))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 0);
|
||||
assert_eq!(component.states.cursor, 0);
|
||||
// See del behaviour here
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 0);
|
||||
assert_eq!(component.states.cursor, 0);
|
||||
// Check del behaviour
|
||||
component.states.input = vec!['h', 'e'];
|
||||
component.states.cursor = 1;
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("h")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 1); // Shouldn't move
|
||||
assert_eq!(component.states.cursor, 1);
|
||||
// Another one (should do nothing)
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("h")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 1); // Shouldn't move
|
||||
assert_eq!(component.states.cursor, 1);
|
||||
// Move cursor right
|
||||
component.states.input = vec!['h', 'e', 'l', 'l', 'o'];
|
||||
component.states.cursor = 1;
|
||||
component.props.input_len = Some(16); // Let's change length
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))), // between 'e' and 'l'
|
||||
Msg::None
|
||||
);
|
||||
//assert_eq!(component.render().unwrap().cursor, 2); // Should increment
|
||||
assert_eq!(component.states.cursor, 2);
|
||||
// Put a character here
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Text(String::from("heallo")));
|
||||
//assert_eq!(component.render().unwrap().cursor, 3);
|
||||
assert_eq!(component.states.cursor, 3);
|
||||
// Move left
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
|
||||
Msg::None
|
||||
);
|
||||
//assert_eq!(component.render().unwrap().cursor, 2); // Should decrement
|
||||
assert_eq!(component.states.cursor, 2);
|
||||
// Go at the end
|
||||
component.states.cursor = 6;
|
||||
// Move right
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
|
||||
Msg::None
|
||||
);
|
||||
//assert_eq!(component.render().unwrap().cursor, 6); // Should stay
|
||||
assert_eq!(component.states.cursor, 6);
|
||||
// Move left
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
|
||||
Msg::None
|
||||
);
|
||||
//assert_eq!(component.render().unwrap().cursor, 5); // Should decrement
|
||||
assert_eq!(component.states.cursor, 5);
|
||||
// Go at the beginning
|
||||
component.states.cursor = 0;
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
|
||||
Msg::None
|
||||
);
|
||||
//assert_eq!(component.render().unwrap().cursor, 0); // Should stay
|
||||
assert_eq!(component.states.cursor, 0);
|
||||
// End - begin
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::End))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.states.cursor, 6);
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Home))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.states.cursor, 0);
|
||||
// Update value
|
||||
component.update(
|
||||
component
|
||||
.get_props()
|
||||
.with_value(PropValue::Str("new-value".to_string()))
|
||||
.build(),
|
||||
);
|
||||
assert_eq!(
|
||||
component.get_value(),
|
||||
Payload::Text(String::from("new-value"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_input_number() {
|
||||
// Instantiate Input with value
|
||||
let mut component: Input = Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_input(InputType::Number)
|
||||
.with_input_len(5)
|
||||
.with_value(PropValue::Str(String::from("3000")))
|
||||
.build(),
|
||||
);
|
||||
// Verify initial state
|
||||
assert_eq!(component.states.cursor, 4);
|
||||
assert_eq!(component.states.input.len(), 4);
|
||||
// Push a non numeric value
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(3000));
|
||||
//assert_eq!(component.render().unwrap().cursor, 4);
|
||||
assert_eq!(component.states.cursor, 4);
|
||||
// Push a number
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('1')))),
|
||||
Msg::None
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(30001));
|
||||
//assert_eq!(component.render().unwrap().cursor, 5);
|
||||
assert_eq!(component.states.cursor, 5);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
//! ## RadioGroup
|
||||
//!
|
||||
//! `RadioGroup` component renders a radio group
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::super::props::TextSpan;
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, PropValue, Props, PropsBuilder};
|
||||
// ext
|
||||
use crossterm::event::KeyCode;
|
||||
use tui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::Spans,
|
||||
widgets::{Block, BorderType, Tabs},
|
||||
};
|
||||
|
||||
// -- states
|
||||
|
||||
/// ## OwnStates
|
||||
///
|
||||
/// OwnStates contains states for this component
|
||||
#[derive(Clone)]
|
||||
struct OwnStates {
|
||||
choice: usize, // Selected option
|
||||
choices: Vec<String>, // Available choices
|
||||
focus: bool, // has focus?
|
||||
}
|
||||
|
||||
impl Default for OwnStates {
|
||||
fn default() -> Self {
|
||||
OwnStates {
|
||||
choice: 0,
|
||||
choices: Vec::new(),
|
||||
focus: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OwnStates {
|
||||
/// ### next_choice
|
||||
///
|
||||
/// Move choice index to next choice
|
||||
pub fn next_choice(&mut self) {
|
||||
if self.choice + 1 < self.choices.len() {
|
||||
self.choice += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### prev_choice
|
||||
///
|
||||
/// Move choice index to previous choice
|
||||
pub fn prev_choice(&mut self) {
|
||||
if self.choice > 0 {
|
||||
self.choice -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### make_choices
|
||||
///
|
||||
/// Set OwnStates choices from a vector of text spans
|
||||
pub fn make_choices(&mut self, spans: &[TextSpan]) {
|
||||
self.choices = spans.iter().map(|x| x.content.clone()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// -- component
|
||||
|
||||
/// ## RadioGroup
|
||||
///
|
||||
/// RadioGroup component represents a group of tabs to select from
|
||||
pub struct RadioGroup {
|
||||
props: Props,
|
||||
states: OwnStates,
|
||||
}
|
||||
|
||||
impl RadioGroup {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new Radio Group component
|
||||
pub fn new(props: Props) -> Self {
|
||||
// Make states
|
||||
let mut states: OwnStates = OwnStates::default();
|
||||
// Update choices (vec of TextSpan to String)
|
||||
states.make_choices(props.texts.rows.as_ref().unwrap_or(&Vec::new()));
|
||||
// Get value
|
||||
if let PropValue::Unsigned(choice) = props.value {
|
||||
states.choice = choice;
|
||||
}
|
||||
RadioGroup { props, states }
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for RadioGroup {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
if self.props.visible {
|
||||
// Make choices
|
||||
let choices: Vec<Spans> = self
|
||||
.states
|
||||
.choices
|
||||
.iter()
|
||||
.map(|x| Spans::from(x.clone()))
|
||||
.collect();
|
||||
// Make colors
|
||||
let (bg, fg, block_color): (Color, Color, Color) = match &self.states.focus {
|
||||
true => (
|
||||
self.props.foreground,
|
||||
self.props.background,
|
||||
self.props.foreground,
|
||||
),
|
||||
false => (Color::Reset, self.props.foreground, Color::Reset),
|
||||
};
|
||||
let title: String = match &self.props.texts.title {
|
||||
Some(t) => t.clone(),
|
||||
None => String::new(),
|
||||
};
|
||||
render.render_widget(
|
||||
Tabs::new(choices)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(self.props.borders)
|
||||
.border_type(BorderType::Rounded)
|
||||
.style(Style::default())
|
||||
.title(title),
|
||||
)
|
||||
.select(self.states.choice)
|
||||
.style(Style::default().fg(block_color))
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(self.props.get_modifiers())
|
||||
.fg(fg)
|
||||
.bg(bg),
|
||||
),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update.
|
||||
/// Returns a Msg to the view
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
// Reset choices
|
||||
self.states
|
||||
.make_choices(props.texts.rows.as_ref().unwrap_or(&Vec::new()));
|
||||
// Get value
|
||||
if let PropValue::Unsigned(choice) = props.value {
|
||||
self.states.choice = choice;
|
||||
}
|
||||
self.props = props;
|
||||
// Msg none
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
PropsBuilder::from(self.props.clone())
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states.
|
||||
/// Returns a Msg to the view
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
// Match event
|
||||
if let InputEvent::Key(key) = ev {
|
||||
match key.code {
|
||||
KeyCode::Right => {
|
||||
// Increment choice
|
||||
self.states.next_choice();
|
||||
// Return Msg On Change
|
||||
Msg::OnChange(self.get_value())
|
||||
}
|
||||
KeyCode::Left => {
|
||||
// Decrement choice
|
||||
self.states.prev_choice();
|
||||
// Return Msg On Change
|
||||
Msg::OnChange(self.get_value())
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Return Submit
|
||||
Msg::OnSubmit(self.get_value())
|
||||
}
|
||||
_ => {
|
||||
// Return key event to activity
|
||||
Msg::OnKey(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Ignore event
|
||||
Msg::None
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get current value from component
|
||||
/// Returns the selected option
|
||||
fn get_value(&self) -> Payload {
|
||||
Payload::Unsigned(self.states.choice)
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component; basically remove focus
|
||||
fn blur(&mut self) {
|
||||
self.states.focus = false;
|
||||
}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component; basically give focus
|
||||
fn active(&mut self) {
|
||||
self.states.focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::ui::layout::props::{TextParts, TextSpan};
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_radio() {
|
||||
// Make component
|
||||
let mut component: RadioGroup = RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("yes or no?")),
|
||||
Some(vec![
|
||||
TextSpan::from("Yes!"),
|
||||
TextSpan::from("No"),
|
||||
TextSpan::from("Maybe"),
|
||||
]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
.build(),
|
||||
);
|
||||
// Verify states
|
||||
assert_eq!(component.states.choice, 1);
|
||||
assert_eq!(component.states.choices.len(), 3);
|
||||
// Focus
|
||||
assert_eq!(component.states.focus, false);
|
||||
component.active();
|
||||
assert_eq!(component.states.focus, true);
|
||||
component.blur();
|
||||
assert_eq!(component.states.focus, false);
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
// Get value
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(1));
|
||||
// Handle events
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
|
||||
Msg::OnChange(Payload::Unsigned(0)),
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(0));
|
||||
// Left again
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Left))),
|
||||
Msg::OnChange(Payload::Unsigned(0)),
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(0));
|
||||
// Right
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
|
||||
Msg::OnChange(Payload::Unsigned(1)),
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(1));
|
||||
// Right again
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
|
||||
Msg::OnChange(Payload::Unsigned(2)),
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(2));
|
||||
// Right again
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Right))),
|
||||
Msg::OnChange(Payload::Unsigned(2)),
|
||||
);
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(2));
|
||||
// Submit
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter))),
|
||||
Msg::OnSubmit(Payload::Unsigned(2)),
|
||||
);
|
||||
// Any key
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('a')))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Char('a'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
//! ## TextList
|
||||
//!
|
||||
//! `TextList` component renders a radio group
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
|
||||
// ext
|
||||
use tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::Style,
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, BorderType, List, ListItem},
|
||||
};
|
||||
|
||||
// -- component
|
||||
|
||||
/// ## Table
|
||||
///
|
||||
/// Table is a table component. List n rows with n text span columns
|
||||
pub struct Table {
|
||||
props: Props,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new Table component
|
||||
pub fn new(props: Props) -> Self {
|
||||
Table { props }
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Table {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
// Make a Span
|
||||
if self.props.visible {
|
||||
let title: String = match self.props.texts.title.as_ref() {
|
||||
Some(t) => t.clone(),
|
||||
None => String::new(),
|
||||
};
|
||||
// Make list entries
|
||||
let list_items: Vec<ListItem> = match self.props.texts.table.as_ref() {
|
||||
None => Vec::new(),
|
||||
Some(table) => table
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let columns: Vec<Span> = row
|
||||
.iter()
|
||||
.map(|col| {
|
||||
Span::styled(
|
||||
col.content.clone(),
|
||||
Style::default()
|
||||
.add_modifier(col.get_modifiers())
|
||||
.fg(col.fg)
|
||||
.bg(col.bg),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
ListItem::new(Spans::from(columns))
|
||||
})
|
||||
.collect(), // Make List item from TextSpan
|
||||
};
|
||||
// Make list
|
||||
render.render_widget(
|
||||
List::new(list_items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(self.props.borders)
|
||||
.border_style(Style::default())
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(title),
|
||||
)
|
||||
.start_corner(Corner::TopLeft),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update.
|
||||
/// Returns a Msg to the view
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// Return None
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
PropsBuilder::from(self.props.clone())
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states.
|
||||
/// Returns a Msg to the view.
|
||||
/// Returns always None, since cannot have any focus
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
// Return key
|
||||
if let InputEvent::Key(key) = ev {
|
||||
Msg::OnKey(key)
|
||||
} else {
|
||||
Msg::None
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get current value from component
|
||||
/// For this component returns always None
|
||||
fn get_value(&self) -> Payload {
|
||||
Payload::None
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component
|
||||
fn blur(&mut self) {}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component
|
||||
fn active(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::ui::layout::props::{TableBuilder, TextParts, TextSpan};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use tui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_table() {
|
||||
let mut component: Table = Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
Some(String::from("My data")),
|
||||
TableBuilder::default()
|
||||
.add_col(TextSpan::from("name"))
|
||||
.add_col(TextSpan::from("age"))
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("omar"))
|
||||
.add_col(TextSpan::from("24"))
|
||||
.build(),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
component.active();
|
||||
component.blur();
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
// Get value
|
||||
assert_eq!(component.get_value(), Payload::None);
|
||||
// Event
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
//! ## Text
|
||||
//!
|
||||
//! `Text` component renders a simple readonly no event associated text
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
|
||||
// ext
|
||||
use tui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Span, Spans, Text as TuiText},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
|
||||
// -- component
|
||||
|
||||
pub struct Text {
|
||||
props: Props,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new Text component
|
||||
pub fn new(props: Props) -> Self {
|
||||
Text { props }
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Text {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
|
||||
/// If focused, cursor is also set (if supported by widget)
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, render: &mut Canvas, area: Rect) {
|
||||
// Make a Span
|
||||
if self.props.visible {
|
||||
let spans: Vec<Span> = match self.props.texts.rows.as_ref() {
|
||||
None => Vec::new(),
|
||||
Some(rows) => rows
|
||||
.iter()
|
||||
.map(|x| {
|
||||
// Keep line color, or use default
|
||||
let fg: Color = match x.fg {
|
||||
Color::Reset => self.props.foreground,
|
||||
_ => x.fg,
|
||||
};
|
||||
let bg: Color = match x.bg {
|
||||
Color::Reset => self.props.background,
|
||||
_ => x.bg,
|
||||
};
|
||||
Span::styled(
|
||||
x.content.clone(),
|
||||
Style::default()
|
||||
.add_modifier(x.get_modifiers())
|
||||
.fg(fg)
|
||||
.bg(bg),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
// Make text
|
||||
let mut text: TuiText = TuiText::from(Spans::from(spans));
|
||||
// Apply style
|
||||
text.patch_style(
|
||||
Style::default()
|
||||
//.add_modifier(self.props.get_modifiers())
|
||||
//.fg(self.props.foreground) NOTE: don't style twice !!!
|
||||
//.bg(self.props.background),
|
||||
);
|
||||
render.render_widget(Paragraph::new(text), area);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update.
|
||||
/// Returns a Msg to the view
|
||||
fn update(&mut self, props: Props) -> Msg {
|
||||
self.props = props;
|
||||
// Return None
|
||||
Msg::None
|
||||
}
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder {
|
||||
PropsBuilder::from(self.props.clone())
|
||||
}
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states.
|
||||
/// Returns a Msg to the view.
|
||||
/// Returns always None, since cannot have any focus
|
||||
fn on(&mut self, ev: InputEvent) -> Msg {
|
||||
// Return key
|
||||
if let InputEvent::Key(key) = ev {
|
||||
Msg::OnKey(key)
|
||||
} else {
|
||||
Msg::None
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get current value from component
|
||||
/// For this component returns always None
|
||||
fn get_value(&self) -> Payload {
|
||||
Payload::None
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component
|
||||
fn blur(&mut self) {}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component
|
||||
fn active(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::ui::layout::props::{TextParts, TextSpan, TextSpanBuilder};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use tui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_text() {
|
||||
let mut component: Text = Text::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpan::from("Press "),
|
||||
TextSpanBuilder::new("<ESC>")
|
||||
.with_foreground(Color::Cyan)
|
||||
.bold()
|
||||
.build(),
|
||||
TextSpan::from(" to quit"),
|
||||
]),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
component.active();
|
||||
component.blur();
|
||||
// Update
|
||||
let props = component.get_props().with_foreground(Color::Red).build();
|
||||
assert_eq!(component.update(props), Msg::None);
|
||||
assert_eq!(component.props.foreground, Color::Red);
|
||||
// Get value
|
||||
assert_eq!(component.get_value(), Payload::None);
|
||||
// Event
|
||||
assert_eq!(
|
||||
component.on(InputEvent::Key(KeyEvent::from(KeyCode::Delete))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Delete))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! ## Layout
|
||||
//!
|
||||
//! `Layout` is the module which provides components, view, state and properties to create layouts
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Modules
|
||||
pub mod components;
|
||||
pub mod props;
|
||||
pub mod utils;
|
||||
pub mod view;
|
||||
|
||||
// locals
|
||||
use props::{PropValue, Props, PropsBuilder};
|
||||
// ext
|
||||
use crossterm::event::Event as InputEvent;
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::io::Stdout;
|
||||
use tui::backend::CrosstermBackend;
|
||||
use tui::layout::Rect;
|
||||
use tui::Frame;
|
||||
|
||||
type Backend = CrosstermBackend<Stdout>;
|
||||
pub(crate) type Canvas<'a> = Frame<'a, Backend>;
|
||||
|
||||
// -- Msg
|
||||
|
||||
/// ## Msg
|
||||
///
|
||||
/// Msg is an enum returned after an event is raised for a certain component
|
||||
/// Yep, I took inspiration from Elm.
|
||||
#[derive(std::fmt::Debug, PartialEq, Eq)]
|
||||
pub enum Msg {
|
||||
OnSubmit(Payload),
|
||||
OnChange(Payload),
|
||||
OnKey(KeyEvent),
|
||||
None,
|
||||
}
|
||||
|
||||
/// ## Payload
|
||||
///
|
||||
/// Payload describes a component value
|
||||
#[derive(std::fmt::Debug, PartialEq, Eq)]
|
||||
pub enum Payload {
|
||||
Text(String),
|
||||
//Signed(isize),
|
||||
Unsigned(usize),
|
||||
None,
|
||||
}
|
||||
|
||||
// -- Component
|
||||
|
||||
/// ## Component
|
||||
///
|
||||
/// Component is a trait which defines the behaviours for a Layout component.
|
||||
/// All layout components must implement a method to render and one to update
|
||||
pub trait Component {
|
||||
/// ### render
|
||||
///
|
||||
/// Based on the current properties and states, renders the component in the provided area frame
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn render(&self, frame: &mut Canvas, area: Rect);
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Properties should first be retrieved through `get_props` which creates a builder from
|
||||
/// existing properties and then edited before calling update.
|
||||
/// Returns a Msg to the view
|
||||
fn update(&mut self, props: Props) -> Msg;
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Returns a props builder starting from component properties.
|
||||
/// This returns a prop builder in order to make easier to create
|
||||
/// new properties for the element.
|
||||
fn get_props(&self) -> PropsBuilder;
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle input event and update internal states.
|
||||
/// Returns a Msg to the view
|
||||
fn on(&mut self, ev: InputEvent) -> Msg;
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get current value from component
|
||||
fn get_value(&self) -> Payload;
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur component; basically remove focus
|
||||
fn blur(&mut self);
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active component; basically give focus
|
||||
fn active(&mut self);
|
||||
}
|
||||
@@ -1,779 +0,0 @@
|
||||
//! ## Props
|
||||
//!
|
||||
//! `Props` is the module which defines properties for layout components
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// ext
|
||||
use tui::style::{Color, Modifier};
|
||||
use tui::widgets::Borders;
|
||||
|
||||
// -- Props
|
||||
|
||||
/// ## Props
|
||||
///
|
||||
/// Props holds all the possible properties for a layout component
|
||||
#[derive(Clone)]
|
||||
pub struct Props {
|
||||
// Values
|
||||
pub visible: bool, // Is the element visible ON CREATE?
|
||||
pub foreground: Color, // Foreground color
|
||||
pub background: Color, // Background color
|
||||
pub borders: Borders, // Borders
|
||||
pub bold: bool, // Text bold
|
||||
pub italic: bool, // Italic
|
||||
pub underlined: bool, // Underlined
|
||||
pub input_type: InputType, // Input type
|
||||
pub input_len: Option<usize>, // max input len
|
||||
pub texts: TextParts, // text parts
|
||||
pub value: PropValue, // Initial value
|
||||
}
|
||||
|
||||
impl Default for Props {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Values
|
||||
visible: true,
|
||||
foreground: Color::Reset,
|
||||
background: Color::Reset,
|
||||
borders: Borders::ALL,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underlined: false,
|
||||
input_type: InputType::Text,
|
||||
input_len: None,
|
||||
texts: TextParts::default(),
|
||||
value: PropValue::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Props {
|
||||
/// ### get_modifiers
|
||||
///
|
||||
/// Get text modifiers from properties
|
||||
pub fn get_modifiers(&self) -> Modifier {
|
||||
Modifier::empty()
|
||||
| (match self.bold {
|
||||
true => Modifier::BOLD,
|
||||
false => Modifier::empty(),
|
||||
})
|
||||
| (match self.italic {
|
||||
true => Modifier::ITALIC,
|
||||
false => Modifier::empty(),
|
||||
})
|
||||
| (match self.underlined {
|
||||
true => Modifier::UNDERLINED,
|
||||
false => Modifier::empty(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -- Props builder
|
||||
|
||||
/// ## PropsBuilder
|
||||
///
|
||||
/// Chain constructor for `Props`
|
||||
pub struct PropsBuilder {
|
||||
props: Option<Props>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl PropsBuilder {
|
||||
/// ### build
|
||||
///
|
||||
/// Build Props from builder
|
||||
/// Don't call this method twice for any reasons!
|
||||
pub fn build(&mut self) -> Props {
|
||||
self.props.take().unwrap()
|
||||
}
|
||||
|
||||
/// ### hidden
|
||||
///
|
||||
/// Initialize props with visible set to False
|
||||
pub fn hidden(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### visible
|
||||
///
|
||||
/// Initialize props with visible set to True
|
||||
pub fn visible(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.visible = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_foreground
|
||||
///
|
||||
/// Set foreground color for component
|
||||
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.foreground = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_background
|
||||
///
|
||||
/// Set background color for component
|
||||
pub fn with_background(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.background = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_borders
|
||||
///
|
||||
/// Set component borders style
|
||||
pub fn with_borders(&mut self, borders: Borders) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.borders = borders;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### bold
|
||||
///
|
||||
/// Set bold property for component
|
||||
pub fn bold(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.bold = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### italic
|
||||
///
|
||||
/// Set italic property for component
|
||||
pub fn italic(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.italic = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### underlined
|
||||
///
|
||||
/// Set underlined property for component
|
||||
pub fn underlined(&mut self) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.underlined = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_texts
|
||||
///
|
||||
/// Set texts for component
|
||||
pub fn with_texts(&mut self, texts: TextParts) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.texts = texts;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_input
|
||||
///
|
||||
/// Set input type for component
|
||||
pub fn with_input(&mut self, input_type: InputType) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.input_type = input_type;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_input_len
|
||||
///
|
||||
/// Set max input len
|
||||
pub fn with_input_len(&mut self, len: usize) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.input_len = Some(len);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_value
|
||||
///
|
||||
/// Set initial value for component
|
||||
pub fn with_value(&mut self, value: PropValue) -> &mut Self {
|
||||
if let Some(props) = self.props.as_mut() {
|
||||
props.value = value;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Props> for PropsBuilder {
|
||||
fn from(props: Props) -> Self {
|
||||
PropsBuilder { props: Some(props) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PropsBuilder {
|
||||
fn default() -> Self {
|
||||
PropsBuilder {
|
||||
props: Some(Props::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Text parts
|
||||
|
||||
/// ## Table
|
||||
///
|
||||
/// Table represents a list of rows with a list of columns of text spans
|
||||
pub type Table = Vec<Vec<TextSpan>>;
|
||||
|
||||
/// ## TextParts
|
||||
///
|
||||
/// TextParts holds optional component for the text displayed by a component
|
||||
#[derive(Clone)]
|
||||
pub struct TextParts {
|
||||
pub title: Option<String>,
|
||||
pub rows: Option<Vec<TextSpan>>,
|
||||
pub table: Option<Table>, // First vector is rows, inner vec is column
|
||||
}
|
||||
|
||||
impl TextParts {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new TextParts entity
|
||||
pub fn new(title: Option<String>, rows: Option<Vec<TextSpan>>) -> Self {
|
||||
TextParts {
|
||||
title,
|
||||
rows,
|
||||
table: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### table
|
||||
///
|
||||
/// Instantiates a new TextParts as a Table
|
||||
pub fn table(title: Option<String>, table: Table) -> Self {
|
||||
TextParts {
|
||||
title,
|
||||
rows: None,
|
||||
table: Some(table),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TextParts {
|
||||
fn default() -> Self {
|
||||
TextParts {
|
||||
title: None,
|
||||
rows: None,
|
||||
table: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ## TableBuilder
|
||||
///
|
||||
/// Table builder is a helper to make it easier to build text tables
|
||||
pub struct TableBuilder {
|
||||
table: Option<Table>,
|
||||
}
|
||||
|
||||
impl TableBuilder {
|
||||
/// ### add_col
|
||||
///
|
||||
/// Add a column to the last row
|
||||
pub fn add_col(&mut self, span: TextSpan) -> &mut Self {
|
||||
if let Some(table) = self.table.as_mut() {
|
||||
if let Some(row) = table.last_mut() {
|
||||
row.push(span);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### add_row
|
||||
///
|
||||
/// Add a new row to the table
|
||||
pub fn add_row(&mut self) -> &mut Self {
|
||||
if let Some(table) = self.table.as_mut() {
|
||||
table.push(vec![]);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### build
|
||||
///
|
||||
/// Take table out of builder
|
||||
/// Don't call this method twice for any reasons!
|
||||
pub fn build(&mut self) -> Table {
|
||||
self.table.take().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TableBuilder {
|
||||
fn default() -> Self {
|
||||
TableBuilder {
|
||||
table: Some(vec![vec![]]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### TextSpan
|
||||
///
|
||||
/// TextSpan is a "cell" of text with its attributes
|
||||
#[derive(Clone, std::fmt::Debug)]
|
||||
pub struct TextSpan {
|
||||
pub content: String,
|
||||
pub fg: Color,
|
||||
pub bg: Color,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub underlined: bool,
|
||||
}
|
||||
|
||||
impl From<&str> for TextSpan {
|
||||
fn from(txt: &str) -> Self {
|
||||
TextSpan {
|
||||
content: txt.to_string(),
|
||||
fg: Color::Reset,
|
||||
bg: Color::Reset,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underlined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for TextSpan {
|
||||
fn from(content: String) -> Self {
|
||||
TextSpan {
|
||||
content,
|
||||
fg: Color::Reset,
|
||||
bg: Color::Reset,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underlined: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextSpan {
|
||||
/// ### get_modifiers
|
||||
///
|
||||
/// Get text modifiers from properties
|
||||
pub fn get_modifiers(&self) -> Modifier {
|
||||
Modifier::empty()
|
||||
| (match self.bold {
|
||||
true => Modifier::BOLD,
|
||||
false => Modifier::empty(),
|
||||
})
|
||||
| (match self.italic {
|
||||
true => Modifier::ITALIC,
|
||||
false => Modifier::empty(),
|
||||
})
|
||||
| (match self.underlined {
|
||||
true => Modifier::UNDERLINED,
|
||||
false => Modifier::empty(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -- TextSpan builder
|
||||
|
||||
/// ## TextSpanBuilder
|
||||
///
|
||||
/// TextSpanBuilder is a struct which helps building quickly a TextSpan
|
||||
pub struct TextSpanBuilder {
|
||||
text: Option<TextSpan>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TextSpanBuilder {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new TextSpanBuilder
|
||||
pub fn new(text: &str) -> Self {
|
||||
TextSpanBuilder {
|
||||
text: Some(TextSpan::from(text)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### with_foreground
|
||||
///
|
||||
/// Set foreground for text span
|
||||
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(text) = self.text.as_mut() {
|
||||
text.fg = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_background
|
||||
///
|
||||
/// Set background for text span
|
||||
pub fn with_background(&mut self, color: Color) -> &mut Self {
|
||||
if let Some(text) = self.text.as_mut() {
|
||||
text.bg = color;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### italic
|
||||
///
|
||||
/// Set italic for text span
|
||||
pub fn italic(&mut self) -> &mut Self {
|
||||
if let Some(text) = self.text.as_mut() {
|
||||
text.italic = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
/// ### bold
|
||||
///
|
||||
/// Set bold for text span
|
||||
pub fn bold(&mut self) -> &mut Self {
|
||||
if let Some(text) = self.text.as_mut() {
|
||||
text.bold = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### underlined
|
||||
///
|
||||
/// Set underlined for text span
|
||||
pub fn underlined(&mut self) -> &mut Self {
|
||||
if let Some(text) = self.text.as_mut() {
|
||||
text.underlined = true;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### build
|
||||
///
|
||||
/// Make TextSpan out of builder
|
||||
/// Don't call this method twice for any reasons!
|
||||
pub fn build(&mut self) -> TextSpan {
|
||||
self.text.take().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
// -- Prop value
|
||||
|
||||
/// ### PropValue
|
||||
///
|
||||
/// PropValue describes a property initial value
|
||||
#[derive(Clone, PartialEq, std::fmt::Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum PropValue {
|
||||
Str(String),
|
||||
Unsigned(usize),
|
||||
Signed(isize),
|
||||
Float(f64),
|
||||
Boolean(bool),
|
||||
None,
|
||||
}
|
||||
|
||||
// -- Input Type
|
||||
|
||||
/// ## InputType
|
||||
///
|
||||
/// Input type for text inputs
|
||||
#[derive(Clone, Copy, PartialEq, std::fmt::Debug)]
|
||||
pub enum InputType {
|
||||
Text,
|
||||
Number,
|
||||
Password,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_default() {
|
||||
let props: Props = Props::default();
|
||||
assert_eq!(props.visible, true);
|
||||
assert_eq!(props.background, Color::Reset);
|
||||
assert_eq!(props.foreground, Color::Reset);
|
||||
assert_eq!(props.borders, Borders::ALL);
|
||||
assert_eq!(props.bold, false);
|
||||
assert_eq!(props.italic, false);
|
||||
assert_eq!(props.underlined, false);
|
||||
assert!(props.texts.title.is_none());
|
||||
assert_eq!(props.input_type, InputType::Text);
|
||||
assert!(props.input_len.is_none());
|
||||
assert_eq!(props.value, PropValue::None);
|
||||
assert!(props.texts.rows.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_modifiers() {
|
||||
// Make properties
|
||||
let props: Props = PropsBuilder::default().bold().italic().underlined().build();
|
||||
// Get modifiers
|
||||
let modifiers: Modifier = props.get_modifiers();
|
||||
assert!(modifiers.intersects(Modifier::BOLD));
|
||||
assert!(modifiers.intersects(Modifier::ITALIC));
|
||||
assert!(modifiers.intersects(Modifier::UNDERLINED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_builder() {
|
||||
let props: Props = PropsBuilder::default()
|
||||
.hidden()
|
||||
.with_background(Color::Blue)
|
||||
.with_foreground(Color::Green)
|
||||
.with_borders(Borders::BOTTOM)
|
||||
.bold()
|
||||
.italic()
|
||||
.underlined()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("hello")),
|
||||
Some(vec![TextSpan::from("hey")]),
|
||||
))
|
||||
.with_input(InputType::Password)
|
||||
.with_input_len(16)
|
||||
.with_value(PropValue::Str(String::from("Hello")))
|
||||
.build();
|
||||
assert_eq!(props.background, Color::Blue);
|
||||
assert_eq!(props.borders, Borders::BOTTOM);
|
||||
assert_eq!(props.bold, true);
|
||||
assert_eq!(props.foreground, Color::Green);
|
||||
assert_eq!(props.italic, true);
|
||||
assert_eq!(props.texts.title.as_ref().unwrap().as_str(), "hello");
|
||||
assert_eq!(props.input_type, InputType::Password);
|
||||
assert_eq!(*props.input_len.as_ref().unwrap(), 16);
|
||||
if let PropValue::Str(s) = props.value {
|
||||
assert_eq!(s.as_str(), "Hello");
|
||||
} else {
|
||||
panic!("Expected value to be a string");
|
||||
}
|
||||
assert_eq!(
|
||||
props
|
||||
.texts
|
||||
.rows
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_str(),
|
||||
"hey"
|
||||
);
|
||||
assert_eq!(props.underlined, true);
|
||||
assert_eq!(props.visible, false);
|
||||
let props: Props = PropsBuilder::default()
|
||||
.visible()
|
||||
.with_background(Color::Blue)
|
||||
.with_foreground(Color::Green)
|
||||
.bold()
|
||||
.italic()
|
||||
.underlined()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("hello")),
|
||||
Some(vec![TextSpan::from("hey")]),
|
||||
))
|
||||
.build();
|
||||
assert_eq!(props.background, Color::Blue);
|
||||
assert_eq!(props.bold, true);
|
||||
assert_eq!(props.foreground, Color::Green);
|
||||
assert_eq!(props.italic, true);
|
||||
assert_eq!(props.texts.title.as_ref().unwrap().as_str(), "hello");
|
||||
assert_eq!(
|
||||
props
|
||||
.texts
|
||||
.rows
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_str(),
|
||||
"hey"
|
||||
);
|
||||
assert_eq!(props.underlined, true);
|
||||
assert_eq!(props.visible, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_ui_layout_props_build_twice() {
|
||||
let mut builder: PropsBuilder = PropsBuilder::default();
|
||||
let _ = builder.build();
|
||||
builder
|
||||
.hidden()
|
||||
.with_background(Color::Blue)
|
||||
.with_foreground(Color::Green)
|
||||
.bold()
|
||||
.italic()
|
||||
.underlined()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("hello")),
|
||||
Some(vec![TextSpan::from("hey")]),
|
||||
));
|
||||
// Rebuild
|
||||
let _ = builder.build();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_builder_from_props() {
|
||||
let props: Props = PropsBuilder::default()
|
||||
.hidden()
|
||||
.with_background(Color::Blue)
|
||||
.with_foreground(Color::Green)
|
||||
.bold()
|
||||
.italic()
|
||||
.underlined()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("hello")),
|
||||
Some(vec![TextSpan::from("hey")]),
|
||||
))
|
||||
.build();
|
||||
// Ok, now make a builder from properties
|
||||
let builder: PropsBuilder = PropsBuilder::from(props);
|
||||
assert!(builder.props.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_text_parts_with_values() {
|
||||
let parts: TextParts = TextParts::new(
|
||||
Some(String::from("Hello world!")),
|
||||
Some(vec![TextSpan::from("row1"), TextSpan::from("row2")]),
|
||||
);
|
||||
assert_eq!(parts.title.as_ref().unwrap().as_str(), "Hello world!");
|
||||
assert_eq!(
|
||||
parts
|
||||
.rows
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_str(),
|
||||
"row1"
|
||||
);
|
||||
assert_eq!(
|
||||
parts
|
||||
.rows
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_str(),
|
||||
"row2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_text_parts_default() {
|
||||
let parts: TextParts = TextParts::default();
|
||||
assert!(parts.title.is_none());
|
||||
assert!(parts.rows.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_text_parts_table() {
|
||||
let table: TextParts = TextParts::table(
|
||||
Some(String::from("my data")),
|
||||
TableBuilder::default()
|
||||
.add_col(TextSpan::from("name"))
|
||||
.add_col(TextSpan::from("age"))
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("christian"))
|
||||
.add_col(TextSpan::from("23"))
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("omar"))
|
||||
.add_col(TextSpan::from("25"))
|
||||
.add_row()
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("pippo"))
|
||||
.build(),
|
||||
);
|
||||
// Verify table
|
||||
assert_eq!(table.title.as_ref().unwrap().as_str(), "my data");
|
||||
assert!(table.rows.is_none());
|
||||
assert_eq!(table.table.as_ref().unwrap().len(), 5); // 5 rows
|
||||
assert_eq!(table.table.as_ref().unwrap().get(0).unwrap().len(), 2); // 2 cols
|
||||
assert_eq!(table.table.as_ref().unwrap().get(1).unwrap().len(), 2); // 2 cols
|
||||
assert_eq!(
|
||||
table
|
||||
.table
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get(1)
|
||||
.unwrap()
|
||||
.get(0)
|
||||
.unwrap()
|
||||
.content
|
||||
.as_str(),
|
||||
"christian"
|
||||
); // check content
|
||||
assert_eq!(table.table.as_ref().unwrap().get(2).unwrap().len(), 2); // 2 cols
|
||||
assert_eq!(table.table.as_ref().unwrap().get(3).unwrap().len(), 0); // 0 cols
|
||||
assert_eq!(table.table.as_ref().unwrap().get(4).unwrap().len(), 1); // 1 cols
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_props_text_span() {
|
||||
// from str
|
||||
let span: TextSpan = TextSpan::from("Hello!");
|
||||
assert_eq!(span.content.as_str(), "Hello!");
|
||||
assert_eq!(span.bold, false);
|
||||
assert_eq!(span.fg, Color::Reset);
|
||||
assert_eq!(span.bg, Color::Reset);
|
||||
assert_eq!(span.italic, false);
|
||||
assert_eq!(span.underlined, false);
|
||||
// From String
|
||||
let span: TextSpan = TextSpan::from(String::from("omar"));
|
||||
assert_eq!(span.content.as_str(), "omar");
|
||||
assert_eq!(span.bold, false);
|
||||
assert_eq!(span.fg, Color::Reset);
|
||||
assert_eq!(span.bg, Color::Reset);
|
||||
assert_eq!(span.italic, false);
|
||||
assert_eq!(span.underlined, false);
|
||||
// With attributes
|
||||
let span: TextSpan = TextSpanBuilder::new("Error")
|
||||
.with_background(Color::Red)
|
||||
.with_foreground(Color::Black)
|
||||
.bold()
|
||||
.italic()
|
||||
.underlined()
|
||||
.build();
|
||||
assert_eq!(span.content.as_str(), "Error");
|
||||
assert_eq!(span.bold, true);
|
||||
assert_eq!(span.fg, Color::Black);
|
||||
assert_eq!(span.bg, Color::Red);
|
||||
assert_eq!(span.italic, true);
|
||||
assert_eq!(span.underlined, true);
|
||||
// Check modifiers
|
||||
let modifiers: Modifier = span.get_modifiers();
|
||||
assert!(modifiers.intersects(Modifier::BOLD));
|
||||
assert!(modifiers.intersects(Modifier::ITALIC));
|
||||
assert!(modifiers.intersects(Modifier::UNDERLINED));
|
||||
}
|
||||
}
|
||||
@@ -1,461 +0,0 @@
|
||||
//! ## View
|
||||
//!
|
||||
//! `View` is the module which handles layout components
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// imports
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder, Rect};
|
||||
// ext
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// ## View
|
||||
///
|
||||
/// View is the wrapper and manager for all the components.
|
||||
/// A View is a container for all the components in a certain layout.
|
||||
/// Each View can have only one focused component.
|
||||
pub struct View {
|
||||
components: HashMap<String, Box<dyn Component>>, // all the components in the view
|
||||
focus: Option<String>, // Current active component
|
||||
focus_stack: Vec<String>, // Focus stack; used to give focus in case the current element loses focus
|
||||
}
|
||||
|
||||
// -- view
|
||||
|
||||
impl View {
|
||||
/// ### init
|
||||
///
|
||||
/// Initialize a new `View`
|
||||
pub fn init() -> Self {
|
||||
View {
|
||||
components: HashMap::new(),
|
||||
focus: None,
|
||||
focus_stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// -- mount / umount
|
||||
|
||||
/// ### mount
|
||||
///
|
||||
/// Mount a new component in the view
|
||||
pub fn mount(&mut self, id: &str, component: Box<dyn Component>) {
|
||||
self.components.insert(id.to_string(), component);
|
||||
}
|
||||
|
||||
/// ### umount
|
||||
///
|
||||
/// Umount a component from the view.
|
||||
/// If component has focus, blur component and remove it from the stack
|
||||
pub fn umount(&mut self, id: &str) {
|
||||
// Check if component has focus
|
||||
if let Some(focus) = self.focus.as_ref() {
|
||||
// If has focus, blur component
|
||||
if focus == id {
|
||||
self.blur();
|
||||
}
|
||||
}
|
||||
// Remove component from focus stack
|
||||
self.pop_from_stack(id);
|
||||
self.components.remove(id);
|
||||
}
|
||||
|
||||
// -- render
|
||||
|
||||
/// ### render
|
||||
///
|
||||
/// RenderData component with the provided id
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
pub fn render(&self, id: &str, frame: &mut Canvas, area: Rect) {
|
||||
if let Some(component) = self.components.get(id) {
|
||||
component.render(frame, area);
|
||||
}
|
||||
}
|
||||
|
||||
// -- props
|
||||
|
||||
/// ### get_props
|
||||
///
|
||||
/// Get component properties
|
||||
pub fn get_props(&self, id: &str) -> Option<PropsBuilder> {
|
||||
self.components.get(id).map(|cmp| cmp.get_props())
|
||||
}
|
||||
|
||||
/// update
|
||||
///
|
||||
/// Update component properties
|
||||
/// Returns `None` if component doesn't exist
|
||||
pub fn update(&mut self, id: &str, props: Props) -> Option<(String, Msg)> {
|
||||
self.components
|
||||
.get_mut(id)
|
||||
.map(|cmp| (id.to_string(), cmp.update(props)))
|
||||
}
|
||||
|
||||
// -- state
|
||||
|
||||
/// ### get_value
|
||||
///
|
||||
/// Get component value
|
||||
pub fn get_value(&self, id: &str) -> Option<Payload> {
|
||||
self.components.get(id).map(|cmp| cmp.get_value())
|
||||
}
|
||||
|
||||
// -- events
|
||||
|
||||
/// ### on
|
||||
///
|
||||
/// Handle event for the focused component (if any)
|
||||
/// Returns `None` if no component is focused
|
||||
pub fn on(&mut self, ev: InputEvent) -> Option<(String, Msg)> {
|
||||
match self.focus.as_ref() {
|
||||
None => None,
|
||||
Some(id) => self
|
||||
.components
|
||||
.get_mut(id)
|
||||
.map(|cmp| (id.to_string(), cmp.on(ev))),
|
||||
}
|
||||
}
|
||||
|
||||
// -- focus
|
||||
|
||||
/// ### blur
|
||||
///
|
||||
/// Blur selected element AND DON'T PUSH CURRENT ACTIVE ELEMENT INTO THE STACK
|
||||
/// Last element in stack becomes active and is removed from the stack
|
||||
pub fn blur(&mut self) {
|
||||
if let Some(component) = self.focus.take() {
|
||||
// Blur component
|
||||
if let Some(cmp) = self.components.get_mut(component.as_str()) {
|
||||
cmp.blur();
|
||||
}
|
||||
// Set last element in the stack as active
|
||||
let mut new: Option<String> = None;
|
||||
if let Some(last) = self.focus_stack.last() {
|
||||
// Set focus to last element
|
||||
new = Some(last.clone());
|
||||
self.focus = Some(last.clone());
|
||||
// Active
|
||||
if let Some(new) = self.components.get_mut(last) {
|
||||
new.active();
|
||||
}
|
||||
}
|
||||
// Pop element from stack
|
||||
if let Some(new) = new {
|
||||
self.pop_from_stack(new.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### active
|
||||
///
|
||||
/// Active provided element
|
||||
/// Current active component, if any, GETS PUSHED to the STACK
|
||||
pub fn active(&mut self, component: &str) {
|
||||
// Active component if exists
|
||||
if let Some(cmp) = self.components.get_mut(component) {
|
||||
// Active component
|
||||
cmp.active();
|
||||
// Put current focus if any, into the stack
|
||||
if let Some(active_component) = self.focus.take() {
|
||||
if active_component != component {
|
||||
// Blur active component if are different
|
||||
if let Some(active_component) =
|
||||
self.components.get_mut(active_component.as_str())
|
||||
{
|
||||
active_component.blur();
|
||||
}
|
||||
}
|
||||
self.push_to_stack(active_component.as_str());
|
||||
}
|
||||
// Give focus to component
|
||||
self.focus = Some(component.to_string());
|
||||
// Remove new component from the stack
|
||||
self.pop_from_stack(component);
|
||||
}
|
||||
}
|
||||
|
||||
// -- private
|
||||
|
||||
/// ### push_to_stack
|
||||
///
|
||||
/// Push component to stack; first remove it from the stack if any
|
||||
fn push_to_stack(&mut self, name: &str) {
|
||||
self.pop_from_stack(name);
|
||||
self.focus_stack.push(name.to_string());
|
||||
}
|
||||
|
||||
/// ### pop_from_stack
|
||||
///
|
||||
/// Pop element from focus stack
|
||||
fn pop_from_stack(&mut self, name: &str) {
|
||||
self.focus_stack.retain(|c| c.as_str() != name);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use super::super::components::input::Input;
|
||||
use super::super::components::text::Text;
|
||||
use super::super::props::{PropValue, TextParts, TextSpan};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_view_init() {
|
||||
let view: View = View::init();
|
||||
// Verify view
|
||||
assert_eq!(view.components.len(), 0);
|
||||
assert!(view.focus.is_none());
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_view_mount_umount() {
|
||||
let mut view: View = View::init();
|
||||
// Mount component
|
||||
let input: &str = "INPUT";
|
||||
view.mount(input, make_component_input());
|
||||
// Verify is mounted
|
||||
assert!(view.components.get(input).is_some());
|
||||
// Mount another
|
||||
let text: &str = "TEXT";
|
||||
view.mount(text, make_component_text());
|
||||
assert!(view.components.get(text).is_some());
|
||||
assert_eq!(view.components.len(), 2);
|
||||
// Verify you cannot have duplicates
|
||||
view.mount(input, make_component_input());
|
||||
assert_eq!(view.components.len(), 2); // length should still be 2
|
||||
// Umount
|
||||
view.umount(text);
|
||||
assert_eq!(view.components.len(), 1);
|
||||
assert!(view.components.get(text).is_none());
|
||||
}
|
||||
|
||||
/*
|
||||
#[test]
|
||||
fn test_ui_layout_view_mount_render() {
|
||||
let mut view: View = View::init();
|
||||
// Mount component
|
||||
let input: &str = "INPUT";
|
||||
view.mount(input, make_component_input());
|
||||
assert!(view.render(input).is_some());
|
||||
assert!(view.render("unexisting").is_none());
|
||||
}
|
||||
*/
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_view_focus() {
|
||||
let mut view: View = View::init();
|
||||
// Prepare ids
|
||||
let input1: &str = "INPUT_1";
|
||||
let input2: &str = "INPUT_2";
|
||||
let input3: &str = "INPUT_3";
|
||||
let text1: &str = "TEXT_1";
|
||||
let text2: &str = "TEXT_2";
|
||||
// Mount components
|
||||
view.mount(input1, make_component_input());
|
||||
view.mount(input2, make_component_input());
|
||||
view.mount(input3, make_component_input());
|
||||
view.mount(text1, make_component_text());
|
||||
view.mount(text2, make_component_text());
|
||||
// Verify focus
|
||||
assert!(view.focus.is_none());
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
// Blur when nothing is selected
|
||||
view.blur();
|
||||
assert!(view.focus.is_none());
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
// Active unexisting component
|
||||
view.active("UNEXISTING-COMPONENT");
|
||||
assert!(view.focus.is_none());
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
// Give focus to a component
|
||||
view.active(input1);
|
||||
// Check focus
|
||||
assert_eq!(view.focus.as_ref().unwrap().as_str(), input1);
|
||||
assert_eq!(view.focus_stack.len(), 0); // NOTE: stack is empty until a focus gets blurred
|
||||
// Active a new component
|
||||
view.active(input2);
|
||||
// Now focus should be on input2, but input 1 should be in the focus stack
|
||||
assert_eq!(view.focus.as_ref().unwrap().as_str(), input2);
|
||||
assert_eq!(view.focus_stack.len(), 1);
|
||||
assert_eq!(view.focus_stack[0].as_str(), input1);
|
||||
// Active input 3
|
||||
view.active(input3);
|
||||
// now focus should be hold by input3, and stack should have len 2
|
||||
assert_eq!(view.focus.as_ref().unwrap().as_str(), input3);
|
||||
assert_eq!(view.focus_stack.len(), 2);
|
||||
assert_eq!(view.focus_stack[0].as_str(), input1);
|
||||
assert_eq!(view.focus_stack[1].as_str(), input2);
|
||||
// blur
|
||||
view.blur();
|
||||
// Focus should now be hold by input2; input 3 should NOT be in the stack
|
||||
assert_eq!(view.focus.as_ref().unwrap().as_str(), input2);
|
||||
assert_eq!(view.focus_stack.len(), 1);
|
||||
assert_eq!(view.focus_stack[0].as_str(), input1);
|
||||
// Active twice
|
||||
view.active(input2);
|
||||
// Nothing should have changed
|
||||
assert_eq!(view.focus.as_ref().unwrap().as_str(), input2);
|
||||
assert_eq!(view.focus_stack.len(), 1);
|
||||
assert_eq!(view.focus_stack[0].as_str(), input1);
|
||||
// Blur again; stack should become empty, whether focus should then be hold by input 1
|
||||
view.blur();
|
||||
assert_eq!(view.focus.as_ref().unwrap().as_str(), input1);
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
// Blur again; now everything should be none
|
||||
view.blur();
|
||||
assert!(view.focus.is_none());
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_view_focus_umount() {
|
||||
let mut view: View = View::init();
|
||||
// Mount component
|
||||
let input: &str = "INPUT";
|
||||
let text: &str = "TEXT";
|
||||
let text2: &str = "TEXT2";
|
||||
view.mount(input, make_component_input());
|
||||
view.mount(text, make_component_text());
|
||||
view.mount(text2, make_component_text());
|
||||
// Give focus to input
|
||||
view.active(input);
|
||||
// Give focus to text
|
||||
view.active(text);
|
||||
view.active(text2);
|
||||
// Stack should have 1 element
|
||||
assert_eq!(view.focus_stack.len(), 2);
|
||||
// Focus should be some
|
||||
assert!(view.focus.is_some());
|
||||
// Umount text
|
||||
view.umount(text2);
|
||||
// Focus should now be hold by 'text'; stack should now have size 1
|
||||
assert_eq!(view.focus.as_ref().unwrap(), text);
|
||||
assert_eq!(view.focus_stack.len(), 1);
|
||||
// Umount input
|
||||
view.umount(input);
|
||||
assert_eq!(view.focus.as_ref().unwrap(), text);
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
// Umount text
|
||||
view.umount(text);
|
||||
assert!(view.focus.is_none());
|
||||
assert_eq!(view.focus_stack.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_view_update() {
|
||||
let mut view: View = View::init();
|
||||
// Prepare ids
|
||||
let text: &str = "TEXT";
|
||||
// Mount text
|
||||
view.mount(text, make_component_text());
|
||||
// Get properties and update
|
||||
let props: Props = view.get_props(text).unwrap().build();
|
||||
// Verify bold is false
|
||||
assert_eq!(props.bold, false);
|
||||
// Update properties and set bold to true
|
||||
let mut builder = view.get_props(text).unwrap();
|
||||
let (id, msg) = view.update(text, builder.bold().build()).unwrap();
|
||||
// Verify return values
|
||||
assert_eq!(id, text);
|
||||
assert_eq!(msg, Msg::None);
|
||||
// Verify bold is now true
|
||||
let props: Props = view.get_props(text).unwrap().build();
|
||||
// Verify bold is false
|
||||
assert_eq!(props.bold, true);
|
||||
// Get properties for unexisting component
|
||||
assert!(view.update("foobar", props).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_view_on() {
|
||||
let mut view: View = View::init();
|
||||
// Prepare ids
|
||||
let text: &str = "TEXT";
|
||||
let input: &str = "INPUT";
|
||||
// Mount
|
||||
view.mount(text, make_component_text());
|
||||
view.mount(input, make_component_input());
|
||||
// Verify current value
|
||||
assert_eq!(view.get_value(text).unwrap(), Payload::None); // Text value is Nothing
|
||||
assert_eq!(
|
||||
view.get_value(input).unwrap(),
|
||||
Payload::Text(String::from("text"))
|
||||
); // Defined in `make_component_input`
|
||||
// Handle events WITHOUT ANY ACTIVE ELEMENT
|
||||
assert!(view
|
||||
.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.is_none());
|
||||
// Active input
|
||||
view.active(input);
|
||||
// Now handle events on input
|
||||
// Try char
|
||||
assert_eq!(
|
||||
view.on(InputEvent::Key(KeyEvent::from(KeyCode::Char('1'))))
|
||||
.unwrap(),
|
||||
(input.to_string(), Msg::None)
|
||||
);
|
||||
// Verify new value
|
||||
assert_eq!(
|
||||
view.get_value(input).unwrap(),
|
||||
Payload::Text(String::from("text1"))
|
||||
);
|
||||
// Verify enter
|
||||
assert_eq!(
|
||||
view.on(InputEvent::Key(KeyEvent::from(KeyCode::Enter)))
|
||||
.unwrap(),
|
||||
(
|
||||
input.to_string(),
|
||||
Msg::OnSubmit(Payload::Text(String::from("text1")))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// ### make_component
|
||||
///
|
||||
/// Make a new component; we'll use Input, which uses a rich set of events
|
||||
fn make_component_input() -> Box<dyn Component> {
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("mytext")), None))
|
||||
.with_value(PropValue::Str(String::from("text")))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
|
||||
fn make_component_text() -> Box<dyn Component> {
|
||||
Box::new(Text::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
None,
|
||||
Some(vec![TextSpan::from("Sample text")]),
|
||||
))
|
||||
.build(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -175,6 +175,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_ui_store() {
|
||||
// Create store
|
||||
|
||||
@@ -52,6 +52,8 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_utils_crypto_aes128() {
|
||||
let key: &str = "MYSUPERSECRETKEY";
|
||||
|
||||
57
src/utils/file.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! ## File
|
||||
//!
|
||||
//! `file` is the module which exposes file related utilities
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// ### open_file
|
||||
///
|
||||
/// Open file provided as parameter
|
||||
pub fn open_file<P>(filename: P, create: bool, write: bool, append: bool) -> io::Result<File>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
OpenOptions::new()
|
||||
.create(create)
|
||||
.write(write)
|
||||
.append(append)
|
||||
.truncate(!append)
|
||||
.open(filename)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_utils_file_open() {
|
||||
let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
assert!(open_file(tmpfile.path(), true, true, true).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -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("******"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||