Compare commits
446 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
150a3cf346 | ||
|
|
b31de185c5 | ||
|
|
477930bef9 | ||
|
|
03bbd6420e | ||
|
|
b21607cd77 | ||
|
|
6c6dadc4e7 | ||
|
|
08b8946429 | ||
|
|
23882df474 | ||
|
|
d8547d8f21 | ||
|
|
c6de101808 | ||
|
|
d0688be5cb | ||
|
|
35f37cc2d3 | ||
|
|
81ae310e3d | ||
|
|
3be890c63a | ||
|
|
64a08e1440 | ||
|
|
fe5c35d789 | ||
|
|
df391dfb6f | ||
|
|
f5ac4207e8 | ||
|
|
b8c54b53d9 | ||
|
|
6be9294e11 | ||
|
|
7676e6e3a1 | ||
|
|
9776ecbe60 | ||
|
|
a1632492ed | ||
|
|
8d74d4c4e5 | ||
|
|
e29ce3d0dd | ||
|
|
e6b952966c | ||
|
|
1ba139aed1 | ||
|
|
f136057484 | ||
|
|
47cd112e69 | ||
|
|
871a02c8b5 | ||
|
|
44ba1111af | ||
|
|
52b35f9232 | ||
|
|
f8a448f5e9 | ||
|
|
37da49f4f8 | ||
|
|
c0ae922264 | ||
|
|
91081cb86a | ||
|
|
af678802bb | ||
|
|
66068ec73c | ||
|
|
32e939c183 | ||
|
|
b610da16a9 | ||
|
|
5dfcba3c51 | ||
|
|
d48e05cd74 | ||
|
|
6f4cb46d94 | ||
|
|
5886d90d16 | ||
|
|
ade7160c20 | ||
|
|
0c22b322ae | ||
|
|
a5ba118393 | ||
|
|
7acf119c77 | ||
|
|
bd00ba7971 | ||
|
|
f94a811dd9 | ||
|
|
deb46eaf41 | ||
|
|
7812d1c37b | ||
|
|
c2d401723f | ||
|
|
142169ee42 | ||
|
|
55e884889c | ||
|
|
67e36fa38f | ||
|
|
3dbe024029 | ||
|
|
96b7aff3b6 | ||
|
|
1ad75adf87 | ||
|
|
423f84353d | ||
|
|
de3983e53f | ||
|
|
15bea93da8 | ||
|
|
f5f84ac346 | ||
|
|
d44e2d8eb0 | ||
|
|
39bcd5e83b | ||
|
|
3dd6dbbb37 | ||
|
|
5a5d2fb162 | ||
|
|
08b855e779 | ||
|
|
8a22259eba | ||
|
|
24d727dec8 | ||
|
|
93c4d1c2e3 | ||
|
|
66f9ace7bd | ||
|
|
f3788ef61a | ||
|
|
b9d801e8bc | ||
|
|
3a1c6cac95 | ||
|
|
8ff7040a0a | ||
|
|
118467e079 | ||
|
|
51f0c56b84 | ||
|
|
88a014807f | ||
|
|
7a5861f32f | ||
|
|
63a627e4d0 | ||
|
|
f0d87ff8c4 | ||
|
|
30c2aa144b | ||
|
|
bf4f24ceec | ||
|
|
3520499289 | ||
|
|
977becd5c9 | ||
|
|
fd4c4a3772 | ||
|
|
7c6a22d3e1 | ||
|
|
41360bb2c5 | ||
|
|
9b00feb286 | ||
|
|
f2681ba0b9 | ||
|
|
7c9548c668 | ||
|
|
7caa4575bd | ||
|
|
16a8fc3ad8 | ||
|
|
cd31cc1fc9 | ||
|
|
2f3c1e7f7f | ||
|
|
61404cfbec | ||
|
|
12ec87235f | ||
|
|
b822e2131a | ||
|
|
3b99a5401f | ||
|
|
5156928bdc | ||
|
|
20e7a66ded | ||
|
|
411f734aef | ||
|
|
2c898a91da | ||
|
|
28f8c82ccf | ||
|
|
54342178e0 | ||
|
|
11af0666ea | ||
|
|
a72ecb39e0 | ||
|
|
47c23c6828 | ||
|
|
00e2a1db31 | ||
|
|
36cc6f445a | ||
|
|
ad9ed8facf | ||
|
|
4a7eb831b8 | ||
|
|
28d51fdcf6 | ||
|
|
4ebb8a3b51 | ||
|
|
e76dcd4638 | ||
|
|
eaada667b3 | ||
|
|
5bc46dd720 | ||
|
|
fe6e0eeab5 | ||
|
|
7e075c5b3d | ||
|
|
86dfc2bf97 | ||
|
|
bda69c661f | ||
|
|
8f3fe14843 | ||
|
|
5c952169b3 | ||
|
|
371ba5c399 | ||
|
|
0f4649ab8d | ||
|
|
2b6f7e4868 | ||
|
|
2e3dc7f7a5 | ||
|
|
2d1af97590 | ||
|
|
1e813b0d4d | ||
|
|
cdbfb3977b | ||
|
|
e9d3684f87 | ||
|
|
021bcf0c97 | ||
|
|
fba6da8120 | ||
|
|
9dbfbd0dc3 | ||
|
|
5980bc1fcb | ||
|
|
b2aaf5c57f | ||
|
|
e17224184e | ||
|
|
042007d9ed | ||
|
|
c832a6cb6f | ||
|
|
3aed691cb8 | ||
|
|
7b92bd22e7 | ||
|
|
581badd101 | ||
|
|
c4bc0af58a | ||
|
|
f75dd5d4e3 | ||
|
|
a4544e35f6 | ||
|
|
56d705e253 | ||
|
|
43c177e04d | ||
|
|
5a2d0b7b0b | ||
|
|
43298dee1c | ||
|
|
26014ecb58 | ||
|
|
57dd06d774 | ||
|
|
e21eb72705 | ||
|
|
00b1dbdffa | ||
|
|
55f74a8244 | ||
|
|
db0c54b781 | ||
|
|
5c9cb7eece | ||
|
|
b2d816d20c | ||
|
|
5b832cea8b | ||
|
|
c1780230e9 | ||
|
|
b90953f65e | ||
|
|
44041863ad | ||
|
|
2692041329 | ||
|
|
4a8ea185e6 | ||
|
|
98861a6bbd | ||
|
|
135d947c39 | ||
|
|
f3cbbb8d81 | ||
|
|
3ecf172fb5 | ||
|
|
c9871a0079 | ||
|
|
744e5a251a | ||
|
|
e89198d9bb | ||
|
|
e61e0c018c | ||
|
|
b57763e688 | ||
|
|
3ccbb325b3 | ||
|
|
3ea345ee8f | ||
|
|
ed2c50daac | ||
|
|
f27d5ea08f | ||
|
|
35ab9ae202 | ||
|
|
bd99665d1c | ||
|
|
93bab299ec | ||
|
|
eb33f93322 | ||
|
|
d165b699f0 | ||
|
|
7817295d8e | ||
|
|
a986e531d9 | ||
|
|
8a3b652dcd | ||
|
|
efbea63154 | ||
|
|
61045fa548 | ||
|
|
da5e1f315d | ||
|
|
85c57ce027 | ||
|
|
6682c07eb6 | ||
|
|
4e887c3429 | ||
|
|
6435271be8 | ||
|
|
c9a77fa65d | ||
|
|
cc5399d36e | ||
|
|
ca1aa5675a | ||
|
|
e21bfbbd14 | ||
|
|
e948d598b0 | ||
|
|
0173d67a3b | ||
|
|
025547a3dc | ||
|
|
af830d603d | ||
|
|
669fd23868 | ||
|
|
4ff7fc079c | ||
|
|
7f24d6db5c | ||
|
|
8c8d01c29c | ||
|
|
d23e6bb60c | ||
|
|
87aa900bc6 | ||
|
|
e8e4cd22a1 | ||
|
|
780cf592e4 | ||
|
|
45db58a0b3 | ||
|
|
f5218bc582 | ||
|
|
c5e2e02415 | ||
|
|
e088772685 | ||
|
|
859daa3107 | ||
|
|
56c580fc80 | ||
|
|
7a9ee697ff | ||
|
|
c16a2f6441 | ||
|
|
b3c4385617 | ||
|
|
e92370bd05 | ||
|
|
da0d5231bf | ||
|
|
c1f6308795 | ||
|
|
54ab24fc0c | ||
|
|
d99efb9de4 | ||
|
|
0c9ed38eb7 | ||
|
|
ec801f1555 | ||
|
|
e0d8879961 | ||
|
|
3f2be65a6c | ||
|
|
a5a745e444 | ||
|
|
7ee142314e | ||
|
|
c412d98ec7 | ||
|
|
d7e5eacd79 | ||
|
|
367fb235f6 | ||
|
|
d68d63b978 | ||
|
|
23ca2baa8c | ||
|
|
ac02928e69 | ||
|
|
cb20589b01 | ||
|
|
1acbf89717 | ||
|
|
08d8a3621c | ||
|
|
0192b86422 | ||
|
|
0e4caaecfd | ||
|
|
215927d432 | ||
|
|
eee08bd623 | ||
|
|
76fdd9864c | ||
|
|
350443ec99 | ||
|
|
928fc1b450 | ||
|
|
03e1bf53d0 | ||
|
|
9330025d07 | ||
|
|
bf56a269e0 | ||
|
|
0393c1a850 | ||
|
|
69ece00ae2 | ||
|
|
97656536d4 | ||
|
|
94b78d85ef | ||
|
|
ba3a888d26 | ||
|
|
d62a6e98c8 | ||
|
|
eeed99b013 | ||
|
|
2b54326334 | ||
|
|
2bd3d33ff6 | ||
|
|
c54a9ef5c9 | ||
|
|
a7ae0159cc | ||
|
|
f981602221 | ||
|
|
fa5468be4a | ||
|
|
591182414f | ||
|
|
49790b4704 | ||
|
|
daa3b3e549 | ||
|
|
120dc8ecb4 | ||
|
|
06a2373776 | ||
|
|
32ae5cc182 | ||
|
|
6975beaf30 | ||
|
|
c141c6c44d | ||
|
|
68cd77a9b3 | ||
|
|
e20a78acef | ||
|
|
6dd4cfaa3c | ||
|
|
d756bf7786 | ||
|
|
65e7ff22f7 | ||
|
|
655084c5f6 | ||
|
|
09bc8a92a2 | ||
|
|
99fd0b199d | ||
|
|
740d906eb3 | ||
|
|
b137fecc12 | ||
|
|
14125f673a | ||
|
|
4911cc5410 | ||
|
|
e0d9ac2ed8 | ||
|
|
5b042e86ef | ||
|
|
8ccf5eb0bb | ||
|
|
c0fdc9b8f8 | ||
|
|
545544ebe2 | ||
|
|
644ea1566c | ||
|
|
f6d1f24b60 | ||
|
|
c9a4706c24 | ||
|
|
7e275a9075 | ||
|
|
22a9eb03b6 | ||
|
|
46ee01e073 | ||
|
|
16a011e81e | ||
|
|
90f28d9f27 | ||
|
|
2e4ff78124 | ||
|
|
9e66207bf7 | ||
|
|
226ad8cc50 | ||
|
|
00731d67d2 | ||
|
|
e354d17c70 | ||
|
|
264b5afad6 | ||
|
|
76c4f1b67f | ||
|
|
96a395615b | ||
|
|
82d8bd0342 | ||
|
|
39e8d1f704 | ||
|
|
6bf503331e | ||
|
|
920d3b4af4 | ||
|
|
f601a0451c | ||
|
|
841db02b30 | ||
|
|
1449b5a524 | ||
|
|
fbb60c7cbc | ||
|
|
f0144e3bc2 | ||
|
|
ce924bb294 | ||
|
|
797174446c | ||
|
|
9b9dc43a7f | ||
|
|
53ee0f618c | ||
|
|
c35a5590b5 | ||
|
|
d23fe09f86 | ||
|
|
e761a90826 | ||
|
|
b5abe4538f | ||
|
|
7202b19d45 | ||
|
|
88f9c8910b | ||
|
|
c9948162b9 | ||
|
|
4e8e0d1b41 | ||
|
|
95b7a773d7 | ||
|
|
07c924d6a0 | ||
|
|
50efebdd63 | ||
|
|
50b8f3cd71 | ||
|
|
093dc4f33d | ||
|
|
963ef88ae5 | ||
|
|
03e63e11ac | ||
|
|
9f478227a9 | ||
|
|
1d112b3f32 | ||
|
|
1a5bd394b6 | ||
|
|
3901ed54c6 | ||
|
|
08728bf55e | ||
|
|
3220d00b14 | ||
|
|
eb12da0308 | ||
|
|
77545ec87d | ||
|
|
3f6f03af33 | ||
|
|
47f9b39630 | ||
|
|
9171e0789f | ||
|
|
5eabaf8ac2 | ||
|
|
b8ad1e7feb | ||
|
|
4d7ea1cdb4 | ||
|
|
021f860ca6 | ||
|
|
d0774fd7ed | ||
|
|
5632ac6f0b | ||
|
|
7a115e5dc3 | ||
|
|
d95cda3dfc | ||
|
|
dd9f54acae | ||
|
|
6bc2bcb89e | ||
|
|
9c2e751e11 | ||
|
|
2a52a19552 | ||
|
|
1b99d63c47 | ||
|
|
33683bc8ce | ||
|
|
61b4a3b76e | ||
|
|
386db6278b | ||
|
|
47664b98ad | ||
|
|
898b57943b | ||
|
|
d37cc4f796 | ||
|
|
b3fed60d12 | ||
|
|
a3d1db3fa2 | ||
|
|
0a79fb3687 | ||
|
|
900d9ac3c6 | ||
|
|
50b523f9f4 | ||
|
|
0759651ee4 | ||
|
|
daedee3f66 | ||
|
|
61c58e227e | ||
|
|
3cd9fc407e | ||
|
|
dd6e2be75d | ||
|
|
38e015efe4 | ||
|
|
335bfc8460 | ||
|
|
344bf8604f | ||
|
|
562a1b3ae8 | ||
|
|
65c541ff2a | ||
|
|
14ddba022f | ||
|
|
10df5abae2 | ||
|
|
443789698b | ||
|
|
52df9bc73b | ||
|
|
8cb3637954 | ||
|
|
ee55d1fd31 | ||
|
|
dcc289153f | ||
|
|
940d8d94e5 | ||
|
|
c9f1086408 | ||
|
|
ff4f35e5f5 | ||
|
|
b865fed7e9 | ||
|
|
274c5e309b | ||
|
|
e7d53a7d00 | ||
|
|
99117e067e | ||
|
|
88e27ee8fe | ||
|
|
d3fe546264 | ||
|
|
982b3ec8d0 | ||
|
|
b8cf2bea77 | ||
|
|
a511cd4ac3 | ||
|
|
0bab9a77a2 | ||
|
|
1f9b616de7 | ||
|
|
71593e3ea7 | ||
|
|
db7ee624e3 | ||
|
|
0e7527fb3f | ||
|
|
50fcba63c4 | ||
|
|
8046f82214 | ||
|
|
c33045b4b8 | ||
|
|
bb2a60e776 | ||
|
|
04e2dc86e8 | ||
|
|
ad44f020a5 | ||
|
|
ad339aa959 | ||
|
|
3ea8188805 | ||
|
|
18aacef89f | ||
|
|
b8b29371ca | ||
|
|
bd99a0c2b2 | ||
|
|
c493ba45a8 | ||
|
|
e9d5093a52 | ||
|
|
7aa8a892f8 | ||
|
|
99a3b64833 | ||
|
|
d94c331569 | ||
|
|
e2e25e4ac1 | ||
|
|
2b8be4d1e9 | ||
|
|
db38a53172 | ||
|
|
29f7e3c759 | ||
|
|
83bc7db048 | ||
|
|
e273192f19 | ||
|
|
55d05be626 | ||
|
|
5c26b10671 | ||
|
|
d29428a53b | ||
|
|
2a8ad5e0ed | ||
|
|
f06f718b47 | ||
|
|
db532cc4b7 | ||
|
|
962843bb97 | ||
|
|
619ac4e753 | ||
|
|
a8a9cb9d2e | ||
|
|
3e72787625 | ||
|
|
794dfb8cee | ||
|
|
d89839443d | ||
|
|
99db7b6d8e | ||
|
|
1fb614642c | ||
|
|
3b33c55d97 | ||
|
|
5aea1d286b | ||
|
|
3dcf33ebe2 | ||
|
|
912da10696 | ||
|
|
8281a840a7 | ||
|
|
57e83a63dd | ||
|
|
f73e43304e | ||
|
|
23ac5089a7 | ||
|
|
55bda874f0 | ||
|
|
0eae159bb9 | ||
|
|
6d49fe4e7d | ||
|
|
5b8c03babd |
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
custom: ['https://www.buymeacoffee.com/veeso']
|
||||
8
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help me improving TermSCP
|
||||
title: "[BUG]"
|
||||
about: Create a report of the bug you've encountered
|
||||
title: "[BUG] - ISSUE_TITLE"
|
||||
labels: bug
|
||||
assignees: ChristianVisintin
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
|
||||
@@ -24,7 +24,7 @@ A clear and concise description of what you expected to happen.
|
||||
- OS: [e.g. GNU/Linux Debian 10]
|
||||
- Architecture [Arm, x86_64, ...]
|
||||
- Rust version
|
||||
- TermSCP version
|
||||
- termscp version
|
||||
- Protocol used
|
||||
- Remote server version and name
|
||||
|
||||
|
||||
21
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,12 +1,23 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for TermSCP
|
||||
title: "[Feature Request]"
|
||||
labels: enhancement
|
||||
assignees: ChristianVisintin
|
||||
about: Suggest an idea to improve termscp
|
||||
title: "[Feature Request] - FEATURE_TITLE"
|
||||
labels: "new feature"
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Describe the feature you'd like to be added
|
||||
Put here a brief introduction to your suggestion.
|
||||
|
||||
### Changes
|
||||
|
||||
The following changes to the application are expected
|
||||
|
||||
- ...
|
||||
|
||||
## Implementation
|
||||
|
||||
Provide any kind of suggestion you propose on how to implement the feature.
|
||||
If you have none, delete this section.
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask what you want about the project
|
||||
title: "[QUESTION] - TITLE"
|
||||
labels: question
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
9
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,4 +1,4 @@
|
||||
# Pull Request Title
|
||||
# ISSUE _NUMBER_ - PULL_REQUEST_TITLE
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
@@ -25,7 +25,10 @@ Please select relevant options.
|
||||
- [ ] My code follows the contribution guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I formatted the code with `cargo fmt`
|
||||
- [ ] I checked my code using `cargo clippy` and reports no warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] Any dependent changes have been merged and published in downstream modules
|
||||
- [ ] I have introduced no new *C-bindings*
|
||||
- [ ] The changes I've made are Windows, MacOS, UNIX, Linux compatible (or I've handled them using `cfg target_os`)
|
||||
- [ ] I increased or maintained the code coverage for the project, compared to the previous commit
|
||||
|
||||
21
.github/workflows/aur-pub.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: aur-pub
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
jobs:
|
||||
aur-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Publish AUR package
|
||||
uses: KSXGitHub/github-actions-deploy-aur@v2.2.3
|
||||
with:
|
||||
pkgname: termscp
|
||||
pkgbuild: ./dist/pkgs/arch/PKGBUILD
|
||||
commit_username: ${{ secrets.AUR_USERNAME }}
|
||||
commit_email: ${{ secrets.AUR_EMAIL }}
|
||||
ssh_private_key: ${{ secrets.AUR_KEY }}
|
||||
commit_message: Update AUR package
|
||||
ssh_keyscan_types: rsa,dsa,ecdsa,ed25519
|
||||
22
.github/workflows/coverage.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: coverage
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Generate coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Run cargo-tarpaulin
|
||||
uses: actions-rs/tarpaulin@v0.1
|
||||
with:
|
||||
args: "--ignore-tests -- --test-threads 1"
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@v1
|
||||
20
.github/workflows/linux.yml
vendored
@@ -7,12 +7,20 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
- uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --all-features --no-fail-fast
|
||||
- name: Format
|
||||
run: cargo fmt --all -- --check
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -Dwarnings
|
||||
|
||||
13
.github/workflows/macos.yml
vendored
@@ -7,12 +7,13 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose --features githubActions -- --test-threads 1
|
||||
- name: Clippy
|
||||
run: cargo clippy
|
||||
|
||||
13
.github/workflows/windows.yml
vendored
@@ -7,12 +7,13 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: windows-2019
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- uses: actions/checkout@v2
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose --features githubActions -- --test-threads 1
|
||||
- name: Clippy
|
||||
run: cargo clippy
|
||||
|
||||
8
.gitignore
vendored
@@ -13,3 +13,11 @@
|
||||
**/*.rs.bk
|
||||
|
||||
# End of https://www.gitignore.io/api/rust
|
||||
|
||||
# Distributions
|
||||
*.rpm
|
||||
*.deb
|
||||
dist/pkgs/arch/*.tar.gz
|
||||
|
||||
# Macos
|
||||
.DS_Store
|
||||
|
||||
237
CHANGELOG.md
@@ -1,11 +1,248 @@
|
||||
# Changelog
|
||||
|
||||
- [Changelog](#changelog)
|
||||
- [0.4.2](#042)
|
||||
- [0.4.1](#041)
|
||||
- [0.4.0](#040)
|
||||
- [0.3.3](#033)
|
||||
- [0.3.2](#032)
|
||||
- [0.3.1](#031)
|
||||
- [0.3.0](#030)
|
||||
- [0.2.0](#020)
|
||||
- [0.1.3](#013)
|
||||
- [0.1.2](#012)
|
||||
- [0.1.1](#011)
|
||||
- [0.1.0](#010)
|
||||
|
||||
---
|
||||
|
||||
## 0.4.2
|
||||
|
||||
Released on 13/04/2021
|
||||
|
||||
- Enhancements:
|
||||
- Use highlight symbol for logbox of `tui-rs` instead of adding a `Span`
|
||||
- Bugfix:
|
||||
- removed `eprintln!` in ftp transfer causing UI to break in Windows
|
||||
|
||||
## 0.4.1
|
||||
|
||||
Released on 07/04/2021
|
||||
|
||||
- Enhancements:
|
||||
- SCP file transfer:
|
||||
- Added possibility to stat directories.
|
||||
- Bugfix:
|
||||
- [Issue 18](https://github.com/veeso/termscp/issues/18): Set file transfer type to `Binary` for FTP
|
||||
- [Issue 17](https://github.com/veeso/termscp/issues/17)
|
||||
- SCP: fixed symlink not properly detected
|
||||
- FTP: added symlink support for Linux targets
|
||||
- [Issue 10](https://github.com/veeso/termscp/issues/10): Fixed port not being loaded from bookmarks into gui
|
||||
- [Issue 9](https://github.com/veeso/termscp/issues/9): Fixed issues related to paths on remote when using Windows
|
||||
- Dependencies:
|
||||
- Added `path-slash 0.1.4` (Windows only)
|
||||
- Added `thiserror 1.0.24`
|
||||
- Updated `edit` to `0.1.3`
|
||||
- Updated `magic-crypt` to `3.1.7`
|
||||
- Updated `rand` to `0.8.3`
|
||||
- Updated `regex` to `1.4.5`
|
||||
- Updated `textwrap` to `0.13.4`
|
||||
- Updated `ureq` to `2.1.0`
|
||||
- Updated `whoami` to `1.1.1`
|
||||
- Updated `wildmatch` to `2.0.0`
|
||||
|
||||
## 0.4.0
|
||||
|
||||
Released on 27/03/2021
|
||||
|
||||
> The UI refactoring update
|
||||
|
||||
- **New explorer features**:
|
||||
- **Execute** a command pressing `X`. This feature is supported on both local and remote hosts (only SFTP/SCP protocols support this feature).
|
||||
- **Find**: search for files pressing `F` using wild matches.
|
||||
- Enhancements:
|
||||
- Input fields will now support **"input keys"** (such as moving cursor, DEL, END, HOME, ...)
|
||||
- Improved performance regarding configuration I/O (config client is now shared in the activity context)
|
||||
- Fetch latest version from Github once; cache previous value in the Context Storage.
|
||||
- Bugfix:
|
||||
- Prevent resetting explorer index on remote tab after performing certain actions (list dir, exec, ...)
|
||||
- SCP file transfer: prevent infinite loops while performing `stat` on symbolic links pointing to themselves (e.g. `mylink -> mylink`)
|
||||
- Fixed a bug causing termscp to crash if removing a bookmark
|
||||
- Fixed file format cursor position in the GUI
|
||||
- Fixed a bug causing termscp to show two equal bookmarks when overwriting one.
|
||||
- Fixed system tests which deleted the termscp configuration when launched
|
||||
- **LICENSE**: changed license to MIT
|
||||
- Dependencies:
|
||||
- Removed `unicode-width`
|
||||
- Added `wildmatch 1.0.13`
|
||||
- For developers:
|
||||
- Activity refactoring
|
||||
- Developed an internal library used to create components, components are then nested inside a View
|
||||
- The new engine works through properties and states, then returns Messages. I was inspired by both React and Elm.
|
||||
|
||||
## 0.3.3
|
||||
|
||||
Released on 28/02/2021
|
||||
|
||||
- **Format key attributes**:
|
||||
- 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
|
||||
- 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)
|
||||
- Added CLI options to set starting workind directory on both local and remote hosts
|
||||
- Parse remote host now uses a Regex to gather parts (increased stability).
|
||||
- Now bookmarks and recents are sorted in the UI (bookmarks are sorted by name; recents are sorted by connection datetime)
|
||||
- Improved stability
|
||||
|
||||
## 0.3.2
|
||||
|
||||
Released on 24/01/2021
|
||||
|
||||
- **Explorer Formatter**:
|
||||
- Added possibility to customize the format when listing files in the explorers (Read more on README)
|
||||
- Added `file_fmt` key to configuration (if missing, default will be used).
|
||||
- Added the text input to the Settings view to set the value for `file_fmt`.
|
||||
- Bugfix:
|
||||
- Solved file index in explorer files at start of termscp, in case the first entry is an hidden file
|
||||
- SCP File transfer: when listing directory entries, check if a symlink points to a directory or to a file
|
||||
- Dependencies:
|
||||
- updated `crossterm` to `0.19.0`
|
||||
- updated `rand` to `0.8.2`
|
||||
- updated `rpassword` to `5.0.1`
|
||||
- updated `serde` to `1.0.121`
|
||||
- updated `tui` to `0.14.0`
|
||||
- updated `whoami` to `1.1.0`
|
||||
|
||||
## 0.3.1
|
||||
|
||||
Released on 18/01/2021
|
||||
|
||||
- **Keyring to store secrets**
|
||||
- On both MacOS and Windows, the secret used to encrypt passwords in bookmarks it is now store in the OS secret vault. This provides much more security to store the password
|
||||
- Enhancements:
|
||||
- Added connection timeout to 30 seconds to SFTP/SCP clients and improved name lookup system.
|
||||
- Bugfix:
|
||||
- Solved index in explorer files list which was no more kept after 0.3.0
|
||||
- SCP file transfer: fixed possible wrong file size when sending file, due to a possible incoherent size between the file explorer and the actual file size.
|
||||
- Breaking changes: on **MacOS / Windows systems only**, the password you saved for bookmarks won't be working anymore if you have support for the keyring crate. Because of the migration to keyring, the previously used secret hasn't been migrated to the storage, instead a new secret will be used. To solve this, just save the bookmark again with the password.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
Released on 10/01/2021
|
||||
|
||||
> The SSH Key Storage Update
|
||||
|
||||
- **SSH Key Storage**
|
||||
- Added the possibility to store SSH private keys to access to remote hosts; this feature is supported in both SFTP and SCP.
|
||||
- SSH Keys can be manipulated through the new **Setup Interface**
|
||||
- **Setup Interface**
|
||||
- Added a new area in the interface, where is possible to customize termscp. Access to this interface is achieved pressing `<CTRL+C>` from the home page (`AuthActivity`).
|
||||
- **Configuration**:
|
||||
- Added configuration; configuration is stored at
|
||||
- Linux: `/home/alice/.config/termscp/config.toml`
|
||||
- MacOS: `/Users/Alice/Library/Application Support/termscp/config.toml`
|
||||
- Windows: `C:\Users\Alice\AppData\Roaming\termscp\config.toml`
|
||||
- Added Text editor to configuration
|
||||
- Added Default File transfer protocol to configuration
|
||||
- Added "Show hidden files" to configuration
|
||||
- Added "Group directories" to configuration
|
||||
- Added SSH keys to configuration; SSH keys will be stored at
|
||||
- Linux: `/home/alice/.config/termscp/.ssh/`
|
||||
- MacOS: `/Users/Alice/Library/Application Support/termscp/.ssh/`
|
||||
- Windows: `C:\Users\Alice\AppData\Roaming\termscp\.ssh\`
|
||||
- Enhancements:
|
||||
- Replaced `sha256` sum with last modification time check, to verify if a file has been changed in the text editor
|
||||
- **FTP**
|
||||
- Added `LIST` command parser for Windows server (DOS-like syntax)
|
||||
- Default protocol changed to default protocol in configuration when providing address as CLI argument
|
||||
- Explorers:
|
||||
- Hidden files are now not shown by default; use `A` to show hidden files.
|
||||
- Append `/` to directories name.
|
||||
- Keybindings:
|
||||
- `A`: Toggle hidden files
|
||||
- `B`: Sort files by (name, size, creation time, modify time)
|
||||
- `N`: New file
|
||||
- Bugfix:
|
||||
- SCP client didn't show file types for files
|
||||
- FTP client didn't show file types for files
|
||||
- FTP file transfer not working properly with `STOR` and `RETR`.
|
||||
- Fixed `0 B/S` transfer rate displayed after completing download in less than 1 second
|
||||
- Dependencies:
|
||||
- added `bitflags 1.2.1`
|
||||
- removed `data-encoding`
|
||||
- updated `ftp` to `4.0.2`
|
||||
- updated `rand` to `0.8.0`
|
||||
- removed `ring`
|
||||
- updated `textwrap` to `0.13.1`
|
||||
- updated `toml` to `0.5.8`
|
||||
- updated `whoami` to `1.0.1`
|
||||
|
||||
## 0.2.0
|
||||
|
||||
Released on 21/12/2020
|
||||
|
||||
> The Bookmarks Update
|
||||
|
||||
- **Bookmarks**
|
||||
- Bookmarks and recent connections are now displayed in the home page
|
||||
- Bookmarks are saved at
|
||||
- Linux: `/home/alice/.config/termscp/bookmarks.toml`
|
||||
- MacOS: `/Users/Alice/Library/Application Support/termscp/bookmarks.toml`
|
||||
- Windows: `C:\Users\Alice\AppData\Roaming\termscp\bookmarks.toml`
|
||||
- **Text Editor**
|
||||
- Added text editor feature to explorer view
|
||||
- Added `o` to keybindings to open a text file
|
||||
- Keybindings:
|
||||
- `C`: Copy file/directory
|
||||
- `O`: Open text file in editor
|
||||
- Enhancements:
|
||||
- User interface
|
||||
- Collpased borders to make everything more *aesthetic*
|
||||
- Rounded input field boards
|
||||
- File explorer:
|
||||
- Log how long it took to upload/download a file and the transfer speed
|
||||
- Display in progress bar the transfer speed (bytes/seconds)
|
||||
- Bugfix:
|
||||
- File mode of file on remote is now reported on local file after being downloaded (unix, linux, macos only)
|
||||
- Scp: when username was not provided, it didn't fallback to current username
|
||||
- Explorer: fixed UID format in Windows
|
||||
|
||||
## 0.1.3
|
||||
|
||||
Released on 14/12/2020
|
||||
|
||||
- Enhancements:
|
||||
- File transfer:
|
||||
- Read buffer is now 65536 bytes long
|
||||
- File explorer:
|
||||
- Fixed color mismatch in local explorer
|
||||
- Explorer tabs have now 70% of layout height, while logging area is 30%
|
||||
- Highlight selected entry in tabs, only when the tab is active
|
||||
- Auth page:
|
||||
- align popup text to center
|
||||
- Keybindings:
|
||||
- `L`: Refresh directory content
|
||||
- Bugfix:
|
||||
- Fixed memory vulnerability in Windows version
|
||||
|
||||
## 0.1.2
|
||||
|
||||
Released on 13/12/2020
|
||||
|
||||
- General performance and code improvements
|
||||
- Improved symlinks management
|
||||
- Possibility to abort file transfers
|
||||
- Enhancements:
|
||||
- File explorer:
|
||||
- When file index is at the end of the list, moving down will set the current index to the first element and viceversa.
|
||||
- Selected file has now colourful background, instead of foreground, for a better readability.
|
||||
- Keybindings:
|
||||
- `E`: Delete file (Same as `DEL`); added because some keyboards don't have `DEL` (hey, that's my MacBook Air's keyboard!)
|
||||
- `Ctrl+C`: Abort transfer process
|
||||
|
||||
## 0.1.1
|
||||
|
||||
Released on 10/12/2020
|
||||
|
||||
285
CONTRIBUTING.md
@@ -1,15 +1,93 @@
|
||||
# Contributing
|
||||
|
||||
Before contributing to this repository, please first discuss the change you wish to make via issue of this repository before making a change.
|
||||
Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it in all your interactions with the project.
|
||||
Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project.
|
||||
|
||||
- [Contributing](#contributing)
|
||||
- [Project mission](#project-mission)
|
||||
- [Project goals](#project-goals)
|
||||
- [Open an issue](#open-an-issue)
|
||||
- [Questions](#questions)
|
||||
- [Bug reports](#bug-reports)
|
||||
- [Feature requests](#feature-requests)
|
||||
- [Preferred contributions](#preferred-contributions)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Software guidelines](#software-guidelines)
|
||||
- [Developer contributions guide](#developer-contributions-guide)
|
||||
- [How TermSCP works](#how-termscp-works)
|
||||
- [Activities](#activities)
|
||||
- [Implementing File Transfers](#implementing-file-transfers)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
> 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.
|
||||
|
||||
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
|
||||
|
||||
- Have support for all the most used file transfer protocol
|
||||
- Provide all the features a file explorer requires
|
||||
- Have a well designed application
|
||||
- Make a reliable, safe and fast application
|
||||
|
||||
---
|
||||
|
||||
## Open an issue
|
||||
|
||||
Open an issue when:
|
||||
|
||||
- You have questions or concerns regarding the project or the application itself.
|
||||
- You have a bug to report.
|
||||
- You have a feature or a suggestion to improve termscp to submit.
|
||||
|
||||
### Questions
|
||||
|
||||
If you have a question open an issue using the `Question` template.
|
||||
By default your question should already be labeled with the `question` label, if you need help with your installation, please also add the `help wanted` label.
|
||||
Check the issue is always assigned to `veeso`.
|
||||
|
||||
### Bug reports
|
||||
|
||||
If you want to report an issue or a bug you've encountered while using termscp, open an issue using the `Bug report` template.
|
||||
The `Bug` label should already be set and the issue should already be assigned to `veeso`.
|
||||
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).
|
||||
Last but not least: the template I've written must be used. Full stop.
|
||||
|
||||
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.
|
||||
|
||||
### Feature requests
|
||||
|
||||
Whenever you have a good idea which chould improve the project, it is a good idea to submit it to the project owner.
|
||||
The first thing you should do though, is not starting to write the code, but is to become concern about how termscp works, what kind
|
||||
of contribution I appreciate and what kind of contribution I won't consider.
|
||||
Said so, follow these steps:
|
||||
|
||||
- Read the contributing guidelines, entirely
|
||||
- Think on whether your idea would fit in the project mission and guidelines or not
|
||||
- Think about the impact your idea would have on the project
|
||||
- Open an issue using the `feature request` template describing with accuracy your suggestion
|
||||
- Wait for the maintainer feedback on your idea
|
||||
|
||||
If you want to implement the feature by yourself and your suggestion gets approved, start writing the code. Remember that on [docs.rs](https://docs.rs/termscp) there is the documentation for the project. Open a PR related to your issue. See [Pull request process for more details](#pull-request-process)
|
||||
|
||||
It is very important to follow these steps, since it will prevent you from working on a feature that will be rejected and trust me, none of us wants to deal with this situation.
|
||||
|
||||
Always mind that your suggestion, may be rejected: I'll always provide a feedback on the reasons that brought me to reject your feature, just try not to get mad about that.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,191 +95,42 @@ Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it
|
||||
|
||||
At the moment, these kind of contributions are more appreciated and should be preferred:
|
||||
|
||||
- Fix for issues described in [Known Issues](./README.md#known-issues) or [issues reported by the community](https://github.com/ChristianVisintin/TermSCP/issues)
|
||||
- Fix for issues described in [Known Issues](./README.md#known-issues-) or [issues reported by the community](https://github.com/veeso/termscp/issues)
|
||||
- New file transfers: for further details see [Implementing File Transfer](#implementing-file-transfers)
|
||||
- Improvements to translators: any improvement to transliteration is accepted if makes sense, consider that my implementations could be not 100% correct (and probably they're not), indeed consider that I don't speak all these languages (tbh I only can speak Russian as a language with a different alphabet from latin - and I can't even speak it very well).
|
||||
- Code optimizations: any optimization to the code is welcome
|
||||
- See also features described in [Upcoming features](./README.md##upcoming-features-). Open an issue first though.
|
||||
- A **logo** for the project: I'd really love to have a logo for termscp 💛
|
||||
|
||||
For any other kind of contribution, especially for new features, please submit an issue first.
|
||||
For any other kind of contribution, especially for new features, please submit a new issue first.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
Let's make it simple and clear:
|
||||
|
||||
1. Open an issue with an **appropriate label** (e.g. bug, enhancement, ...).
|
||||
2. Write a **properly documentation** compliant with **rustdoc** standard.
|
||||
3. Write tests for your code. This doesn't apply necessarily for implementation regarding the user-interface module (`ui`).
|
||||
4. Report changes to the issue you opened, writing a report of what you changed and what you have introduced.
|
||||
5. Update the `CHANGELOG.md` file with details of changes to the application.
|
||||
6. Request maintainers to merge your changes.
|
||||
1. Open a PR with an **appropriate label** (e.g. bug, enhancement, ...).
|
||||
2. Write a **properly documentation** for your software compliant with **rustdoc** standard.
|
||||
3. Write tests for your code. This doesn't apply necessarily for implementation regarding the user-interface module (`ui/activities`) and (if a test server is not available) for file transfers.
|
||||
4. Check your code with `cargo clippy`.
|
||||
5. Check if the CI for your commits reports three-green.
|
||||
6. Report changes to the PR you opened, writing a report of what you changed and what you have introduced.
|
||||
7. Update the `CHANGELOG.md` file with details of changes to the application. In changelog report changes under a chapter called `PR{PULL_REQUEST_NUMBER}` (e.g. PR12).
|
||||
8. Assign a maintainer to the reviewers.
|
||||
9. Request maintainers to merge your changes.
|
||||
|
||||
### Software guidelines
|
||||
|
||||
In addition to the process described for the PRs, I've also decided to introduce a list of guidelines to follow when writing the code, that should be followed:
|
||||
|
||||
1. **Let's stop the NPM apocalypse**: personally I'm against the abuse of dependencies we make in software projects and I think that NodeJS has opened the way to this drama (and has already gone too far). Nowadays nobody cares about adding hundreds of dependencies to their projects. Don't misunderstand me: I think that package managers are cool, but I'm totally against the abuse we're making of them. I think when we work on a project, we should try to use the minor quantity of dependencies as possible, especially because it's not hard to see how many libraries are getting abandoned right now, causing compatibility issues after a while. So please, when working on termscp, try not to add useless dependencies.
|
||||
2. **No C-bindings**: personally I think that Rust still relies too much on C. And that's bad, really bad. Many libraries in Rust are just wrappers to C libraries, which is a huge problem, especially considering this is a multiplatform project. Everytime you add a C-binding to your project, you're forcing your users to install additional libraries to their systems. Sometimes these libraries are already installed on their systems (as happens for libssh2 or openssl in this case), but sometimes not. So if you really have to add a dependency to this project, please AVOID completely adding C-bounded libraries.
|
||||
3. **Test units matter**: Whenever you implement something new to this project, always implement test units which cover the most cases as possible.
|
||||
4. **Comments are useful**: Many people say that the code should be that simple to talk by itself about what it does, and comments should then be useless. I personally don't agree. I'm not saying they're wrong, but I'm just saying that this approach has, in my personal opinion, many aspects which are underrated:
|
||||
1. What's obvious for me, might not be for the others.
|
||||
2. Our capacity to work on a code depends mostly on **time and experience**, not on complexity: I'm not denying complexity matter, but the most decisive factor when working on code is the experience we've acquired working on it and the time we've spent. As the author of the project, I know the project like the back of my hands, but if I didn't work on it for a year, then I would probably have some problems in working on it again as the same speed as before. And do you know what's really time-saving in these cases? Comments.
|
||||
|
||||
## Developer contributions guide
|
||||
|
||||
Welcome to the contributions guide for TermSCP. This chapter DOESN'T contain the documentation for TermSCP, 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
|
||||
|
||||
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.
|
||||
- the **ui**: this module contains the implementation of the user interface, as we'll see in the next chapter, this is achieved through **activities**.
|
||||
- the **activity_manager**: the activity manager takes care of managing activities, basically it runs the activities of the user interface, and chooses, based on their state, when is the moment to terminate the current activity and which activity to run after the current one.
|
||||
|
||||
#### Activities
|
||||
|
||||
Just a little paragraph about activities. Really, read the code and the documentation to have a clear idea of how the ui works.
|
||||
I think there are many ways to implement a user interface; for termscp, I decided to go for a **Android-like** approach.
|
||||
Android works with Activity, each activity represents a view and there is a context shared between them, and so it works here.
|
||||
|
||||
Just a little note about activities, activities work with a `Context`, the context is a data holder for different data, which are shared and common between the activities.
|
||||
|
||||
I've implemented a Trait called `Activity`, which, is a very very reduced version of the Android activity of course.
|
||||
This trait provides only 3 methods:
|
||||
|
||||
- `on_create`: this method must initialize the activity; the context is passed to the activity, which will be the only owner of the Context, until the activity terminates.
|
||||
- `on_draw`: this method must be called each time you want to perform an update of the user interface. This is basically the run method of the activity. This method also cares about handling input events. The developer shouldn't draw the interface on each call of this method (consider that this method might be called hundreds of times per second), but only when actually something has changed (for example after an input event has been raised).
|
||||
- `on_destroy`: this method finalizes the activity and drops it; this method returns the Context to the caller (the activity manager).
|
||||
|
||||
---
|
||||
|
||||
### 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.
|
||||
|
||||
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).
|
||||
|
||||
1. Add the Scp protocol to the `FileTransferProtocol` enum.
|
||||
|
||||
Move to `src/filetransfer/mod.rs` and add `Scp` to the `FileTransferProtocol` enum
|
||||
|
||||
```rs
|
||||
/// ## FileTransferProtocol
|
||||
///
|
||||
/// This enum defines the different transfer protocol available in TermSCP
|
||||
#[derive(std::cmp::PartialEq, std::fmt::Debug, std::clone::Clone)]
|
||||
pub enum FileTransferProtocol {
|
||||
Sftp,
|
||||
Ftp(bool), // Bool is for secure (true => ftps)
|
||||
Scp, // <-- here
|
||||
}
|
||||
```
|
||||
|
||||
In this case Scp is a "plain" enum type. If you need particular options, follow the implementation of `Ftp` which uses a boolean flag for indicating if using FTPS or FTP.
|
||||
|
||||
2. Implement the FileTransfer struct
|
||||
|
||||
Create a file at `src/filetransfer/mytransfer.rs`
|
||||
|
||||
Declare your file transfer struct
|
||||
|
||||
```rs
|
||||
/// ## ScpFileTransfer
|
||||
///
|
||||
/// SFTP file transfer structure
|
||||
pub struct ScpFileTransfer {
|
||||
session: Option<Session>,
|
||||
sftp: Option<Sftp>,
|
||||
wrkdir: PathBuf,
|
||||
}
|
||||
```
|
||||
|
||||
3. Implement the `FileTransfer` trait for it
|
||||
|
||||
You'll have to implement the following methods for your file transfer:
|
||||
|
||||
- connect: connect to remote server
|
||||
- disconnect: disconnect from remote server
|
||||
- is_connected: returns whether the file transfer is connected to remote
|
||||
- pwd: get working directory
|
||||
- change_dir: change working directory.
|
||||
- list_dir: get files and directories at a certain path
|
||||
- mkdir: make a new directory. Return an error in case the directory already exists
|
||||
- remove: remove a file or a directory. In case the protocol doesn't support recursive removing of directories you MUST implement this through a recursive algorithm
|
||||
- rename: rename a file or a directory
|
||||
- stat: returns detail for a certain path
|
||||
- send_file: opens a stream to a remote path for write purposes (write a remote file)
|
||||
- recv_file: opens a stream to a remote path for read purposes (write a local file)
|
||||
- on_sent: finalize a stream when writing a remote file. In case it's not necessary just return `Ok(())`
|
||||
- on_recv: fianlize a stream when reading a remote file. In case it's not necessary just return `Ok(())`
|
||||
|
||||
In case the protocol you're working on doesn't support any of this features, just return `Err(FileTransferError::new(FileTransferErrorType::UnsupportedFeature))`
|
||||
|
||||
4. Add your transfer to filetransfers:
|
||||
|
||||
Move to `src/filetransfer/mod.rs` and declare your file transfer:
|
||||
|
||||
```rs
|
||||
// Transfers
|
||||
pub mod ftp_transfer;
|
||||
pub mod scp_transfer; // <-- here
|
||||
pub mod sftp_transfer;
|
||||
```
|
||||
|
||||
5. Handle FileTransfer in `FileTransferActivity::new`
|
||||
|
||||
Move to `src/ui/activities/filetransfer_activity/mod.rs` and add the new protocol to the client match
|
||||
|
||||
```rs
|
||||
client: match protocol {
|
||||
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new()),
|
||||
FileTransferProtocol::Ftp(ftps) => Box::new(FtpFileTransfer::new(ftps)),
|
||||
FileTransferProtocol::Scp => Box::new(ScpFileTransfer::new()), // <--- here
|
||||
},
|
||||
```
|
||||
|
||||
6. Handle right/left input events in `AuthActivity`:
|
||||
|
||||
Move to `src/ui/activities/auth_activity.rs` and handle the new protocol in `handle_input_event_mode_text` for `KeyCode::Left` and `KeyCode::Right`.
|
||||
Consider that the order they "rotate" must match the way they will be drawned in the interface.
|
||||
For newer protocols, please put them always at the end of the list. In this list I won't, because Scp is more important than Ftp imo.
|
||||
|
||||
```rs
|
||||
KeyCode::Left => {
|
||||
// If current field is Protocol handle event... (move element left)
|
||||
if self.selected_field == InputField::Protocol {
|
||||
self.protocol = match self.protocol {
|
||||
FileTransferProtocol::Sftp => FileTransferProtocol::Ftp(true), // End of list (wrap)
|
||||
FileTransferProtocol::Scp => FileTransferProtocol::Sftp,
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => FileTransferProtocol::Scp,
|
||||
true => FileTransferProtocol::Ftp(false),
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
// If current field is Protocol handle event... ( move element right )
|
||||
if self.selected_field == InputField::Protocol {
|
||||
self.protocol = match self.protocol {
|
||||
FileTransferProtocol::Sftp => FileTransferProtocol::Scp,
|
||||
FileTransferProtocol::Scp => FileTransferProtocol::Ftp(false),
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => FileTransferProtocol::Ftp(true),
|
||||
true => FileTransferProtocol::Sftp, // End of list (wrap)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. Add your new file transfer to the protocol input field
|
||||
|
||||
Move to `AuthActivity::draw_protocol_select` method.
|
||||
Here add your new protocol to the `Spans` vector and to the match case, which chooses which element to highlight.
|
||||
|
||||
```rs
|
||||
let protocols: Vec<Spans> = vec![Spans::from("SFTP"), Spans::from("SCP"), Spans::from("FTP"), Spans::from("FTPS")];
|
||||
let index: usize = match self.protocol {
|
||||
FileTransferProtocol::Sftp => 0,
|
||||
FileTransferProtocol::Scp => 1,
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => 2,
|
||||
true => 3,
|
||||
}
|
||||
};
|
||||
```
|
||||
You can view the developer guide [here](docs/developer.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
1089
Cargo.lock
generated
105
Cargo.toml
@@ -1,47 +1,17 @@
|
||||
[package]
|
||||
name = "termscp"
|
||||
version = "0.1.1"
|
||||
authors = ["Christian Visintin"]
|
||||
edition = "2018"
|
||||
license = "GPL-3.0"
|
||||
keywords = ["scp-client", "sftp-client", "ftp-client", "winscp", "command-line-utility"]
|
||||
categories = ["command-line-utilities"]
|
||||
description = "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."
|
||||
homepage = "https://github.com/ChristianVisintin/TermSCP"
|
||||
repository = "https://github.com/ChristianVisintin/TermSCP"
|
||||
documentation = "https://docs.rs/termscp"
|
||||
readme = "README.md"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.18.2"
|
||||
ftp4 = { version = "^4.0.1", features = ["secure"] }
|
||||
getopts = "0.2.21"
|
||||
ssh2 = "0.9.0"
|
||||
tui = { version = "0.13.0", features = ["crossterm"], default-features = false }
|
||||
whoami = "1.0.0"
|
||||
rpassword = "5.0.0"
|
||||
unicode-width = "0.1.7"
|
||||
chrono = "0.4.19"
|
||||
bytesize = "1.0.1"
|
||||
textwrap = "0.13.0"
|
||||
regex = "1.4.2"
|
||||
lazy_static = "1.4.0"
|
||||
hostname = "0.3.1"
|
||||
|
||||
[target.'cfg(any(unix, macos, linux))'.dependencies]
|
||||
users = "0.11.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
#[patch.crates-io]
|
||||
#ftp = { git = "https://github.com/ChristianVisintin/rust-ftp" }
|
||||
|
||||
[[bin]]
|
||||
edition = "2018"
|
||||
homepage = "https://github.com/veeso/termscp"
|
||||
include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
|
||||
keywords = ["scp-client", "sftp-client", "ftp-client", "winscp", "command-line-utility"]
|
||||
license = "MIT"
|
||||
name = "termscp"
|
||||
path = "src/main.rs"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/veeso/termscp"
|
||||
version = "0.4.2"
|
||||
|
||||
[package.metadata.rpm]
|
||||
package = "termscp"
|
||||
@@ -51,3 +21,62 @@ buildflags = ["--release"]
|
||||
|
||||
[package.metadata.rpm.targets]
|
||||
termscp = { path = "/usr/bin/termscp" }
|
||||
|
||||
[[bin]]
|
||||
name = "termscp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
bitflags = "1.2.1"
|
||||
bytesize = "1.0.1"
|
||||
chrono = "0.4.19"
|
||||
content_inspector = "0.2.4"
|
||||
crossterm = "0.19.0"
|
||||
dirs = "3.0.1"
|
||||
edit = "0.1.3"
|
||||
getopts = "0.2.21"
|
||||
hostname = "0.3.1"
|
||||
lazy_static = "1.4.0"
|
||||
magic-crypt = "3.1.7"
|
||||
rand = "0.8.3"
|
||||
regex = "1.4.5"
|
||||
rpassword = "5.0.1"
|
||||
ssh2 = "0.9.0"
|
||||
tempfile = "3.1.0"
|
||||
textwrap = "0.13.4"
|
||||
thiserror = "^1.0.0"
|
||||
toml = "0.5.8"
|
||||
whoami = "1.1.1"
|
||||
wildmatch = "2.0.0"
|
||||
|
||||
[dependencies.ftp4]
|
||||
features = ["secure"]
|
||||
version = "^4.0.2"
|
||||
|
||||
[dependencies.serde]
|
||||
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"
|
||||
|
||||
[features]
|
||||
githubActions = []
|
||||
|
||||
[target."cfg(any(target_os = \"unix\", target_os = \"macos\", target_os = \"linux\"))"]
|
||||
[target."cfg(any(target_os = \"unix\", target_os = \"macos\", target_os = \"linux\"))".dependencies]
|
||||
users = "0.11.0"
|
||||
|
||||
[target."cfg(any(target_os = \"windows\", target_os = \"macos\"))"]
|
||||
[target."cfg(any(target_os = \"windows\", target_os = \"macos\"))".dependencies]
|
||||
keyring = "0.10.1"
|
||||
|
||||
[target."cfg(target_os = \"windows\")"]
|
||||
[target."cfg(target_os = \"windows\")".dependencies]
|
||||
path-slash = "0.1.4"
|
||||
|
||||
695
LICENSE
@@ -1,674 +1,21 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
TermSCP
|
||||
Copyright (C) 2020 Christian Visintin
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
TermSCP Copyright (C) 2020 Christian Visintin
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
MIT License
|
||||
|
||||
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.
|
||||
|
||||
319
README.md
@@ -1,12 +1,12 @@
|
||||
# TermSCP
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0) [](https://github.com/ChristianVisintin/TermSCP) [](https://github.com/ChristianVisintin/TermSCP/issues) [](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/ChristianVisintin/TermSCP/actions) [](https://github.com/ChristianVisintin/TermSCP/actions) [](https://github.com/ChristianVisintin/TermSCP/actions)
|
||||
[](https://github.com/veeso/termscp/actions) [](https://github.com/veeso/termscp/actions) [](https://github.com/veeso/termscp/actions)
|
||||
|
||||
~ Basically, WinSCP on a terminal ~
|
||||
Developed by Christian Visintin
|
||||
Current version: 0.1.1 (10/12/2020)
|
||||
Current version: 0.4.2 (13/04/2021)
|
||||
|
||||
---
|
||||
|
||||
@@ -14,23 +14,32 @@ Current version: 0.1.1 (10/12/2020)
|
||||
- [About TermSCP 🖥](#about-termscp-)
|
||||
- [Why TermSCP 🤔](#why-termscp-)
|
||||
- [Features 🎁](#features-)
|
||||
- [Installation ▶](#installation-)
|
||||
- [Installation 🛠](#installation-)
|
||||
- [Cargo 🦀](#cargo-)
|
||||
- [Deb package 📦](#deb-package-)
|
||||
- [RPM Package 📦](#rpm-package-)
|
||||
- [RPM package 📦](#rpm-package-)
|
||||
- [AUR Package 🔼](#aur-package-)
|
||||
- [Chocolatey 🍫](#chocolatey-)
|
||||
- [Brew 🍻](#brew-)
|
||||
- [Usage ❓](#usage-)
|
||||
- [Address argument](#address-argument)
|
||||
- [How Password can be provided](#how-password-can-be-provided)
|
||||
- [Address argument 🌎](#address-argument-)
|
||||
- [How Password can be provided 🔐](#how-password-can-be-provided-)
|
||||
- [Bookmarks ⭐](#bookmarks-)
|
||||
- [Are my passwords Safe 😈](#are-my-passwords-safe-)
|
||||
- [Text Editor ✏](#text-editor-)
|
||||
- [How do I configure the text editor 🦥](#how-do-i-configure-the-text-editor-)
|
||||
- [Configuration ⚙️](#configuration-️)
|
||||
- [SSH Key Storage 🔐](#ssh-key-storage-)
|
||||
- [File Explorer Format](#file-explorer-format)
|
||||
- [Keybindings ⌨](#keybindings-)
|
||||
- [Documentation 📚](#documentation-)
|
||||
- [Known issues 🧻](#known-issues-)
|
||||
- [Upcoming Features 🧪](#upcoming-features-)
|
||||
- [Contributions 🤙🏻](#contributions-)
|
||||
- [Contributing and issues 🤝🏻](#contributing-and-issues-)
|
||||
- [Changelog ⏳](#changelog-)
|
||||
- [Powered by 🚀](#powered-by-)
|
||||
- [Gallery 🎬](#gallery-)
|
||||
- [Buy me a coffee ☕](#buy-me-a-coffee-)
|
||||
- [License 📃](#license-)
|
||||
|
||||
---
|
||||
@@ -45,22 +54,32 @@ TermSCP is basically a porting of WinSCP to terminal. So basically is a terminal
|
||||
|
||||
### 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 then 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 midnight commander too, but actually I don't like it very much tbh (and hasn't a decent support for scp).
|
||||
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
|
||||
- Different communication protocols support
|
||||
- SFTP
|
||||
- SCP
|
||||
- FTP and FTPS
|
||||
- Practical user interface to explore and operate on the remote and on the local machine file system
|
||||
- Compatible with Windows, Linux, BSD and MacOS
|
||||
- Handy user interface to explore and operate on the remote and on the local machine file system
|
||||
- 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:
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## Installation ▶
|
||||
## Installation 🛠
|
||||
|
||||
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)
|
||||
@@ -72,10 +91,17 @@ If you want to contribute to this project, don't forget to check out our contrib
|
||||
cargo install termscp
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- Linux
|
||||
- pkg-config
|
||||
- libssh2
|
||||
- openssl
|
||||
|
||||
### Deb package 📦
|
||||
|
||||
Get `deb` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.1_amd64.deb)
|
||||
or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.1_amd64.deb`
|
||||
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:
|
||||
|
||||
@@ -85,10 +111,10 @@ dpkg -i termscp_*.deb
|
||||
gdebi termscp_*.deb
|
||||
```
|
||||
|
||||
### RPM Package 📦
|
||||
### RPM package 📦
|
||||
|
||||
Get `rpm` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.1-1.x86_64.rpm)
|
||||
or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.1-1.x86_64.rpm`
|
||||
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:
|
||||
|
||||
@@ -96,6 +122,14 @@ then install through rpm:
|
||||
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/)
|
||||
@@ -106,7 +140,7 @@ Start PowerShell as administrator and run
|
||||
choco install termscp
|
||||
```
|
||||
|
||||
Alternatively you can download the ZIP file from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp.0.1.1.nupkg)
|
||||
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:
|
||||
|
||||
@@ -121,8 +155,7 @@ You can install TermSCP on MacOS using [brew](https://brew.sh/)
|
||||
From your terminal run
|
||||
|
||||
```sh
|
||||
brew tap ChristianVisintin/termscp
|
||||
brew install termscp
|
||||
brew install veeso/termscp/termscp
|
||||
```
|
||||
|
||||
---
|
||||
@@ -131,6 +164,8 @@ brew install termscp
|
||||
|
||||
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
|
||||
- `-v, --version` Print version info
|
||||
- `-h, --help` Print help page
|
||||
@@ -139,23 +174,25 @@ TermSCP can be started in two different mode, if no extra arguments is provided,
|
||||
|
||||
Alternatively, the user can provide an address as argument to skip the authentication form and starting directly the connection to the remote server.
|
||||
|
||||
### Address argument
|
||||
If address argument is provided you can also provide the start working directory for local host
|
||||
|
||||
### Address argument 🌎
|
||||
|
||||
The address argument has the following syntax:
|
||||
|
||||
```txt
|
||||
[protocol]://[username@]<address>[:port]
|
||||
[protocol://][username@]<address>[:port][:wrkdir]
|
||||
```
|
||||
|
||||
Let's see some example of this particular syntax, since it's very comfortable and you'll probably going to use this instead of the other one...
|
||||
|
||||
- Connect using default protocol (sftp) to 192.168.1.31, port is default for this protocol (22); username is current user's name
|
||||
- Connect using default protocol (*defined in configuration*) to 192.168.1.31, port if not provided is default for the selected protocol (in this case depends on your configuration); username is current user's name
|
||||
|
||||
```sh
|
||||
termscp 192.168.1.31
|
||||
```
|
||||
|
||||
- Connect using default protocol (sftp) to 192.168.1.31, port is default for this protocol (22); username is `root`
|
||||
- Connect using default protocol (*defined in configuration*) to 192.168.1.31; username is `root`
|
||||
|
||||
```sh
|
||||
termscp root@192.168.1.31
|
||||
@@ -167,7 +204,13 @@ Let's see some example of this particular syntax, since it's very comfortable an
|
||||
termscp scp://omar@192.168.1.31:4022
|
||||
```
|
||||
|
||||
#### How Password can be provided
|
||||
- Connect using scp to 192.168.1.31, port is 4022; username is `omar`. You will start in directory `/tmp`
|
||||
|
||||
```sh
|
||||
termscp scp://omar@192.168.1.31:4022:/tmp
|
||||
```
|
||||
|
||||
#### How Password can be provided 🔐
|
||||
|
||||
You have probably noticed, that, when providing the address as argument, there's no way to provide the password.
|
||||
Password can be basically provided through 3 ways when address argument is provided:
|
||||
@@ -178,29 +221,150 @@ Password can be basically provided through 3 ways when address argument is provi
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
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:
|
||||
|
||||
- `$HOME/.config/termscp/` on Linux/BSD
|
||||
- `$HOME/Library/Application Support/termscp` on MacOs
|
||||
- `FOLDERID_RoamingAppData\termscp\` on Windows
|
||||
|
||||
For bookmarks only (this won't apply to recent hosts) it is also possible to save the password used to authenticate. The password is not saved by default and must be specified through the prompt when saving a new Bookmark.
|
||||
|
||||
> I was very undecided about storing passwords in termscp. The reason? Saving a password on your computer might give access to a hacker to any server you've registered. But I must admit by myself that for many machines typing the password everytime is really boring, also many times I have to work with machines in LAN, which wouldn't provide any advantage to an attacker, So I came out with a good compromise for passwords.
|
||||
|
||||
I warmly suggest you to follow these guidelines in order to decide whether you should or you shouldn't save passwords:
|
||||
|
||||
- **DON'T** save passwords for machines which are exposed on the internet, save passwords only for machines in LAN
|
||||
- Make sure your machine is protected by attackers. If possible encrypt your disk and don't leave your PC unlocked while you're away.
|
||||
- Preferably, save passwords only when a compromising of the target machine wouldn't be a problem.
|
||||
|
||||
To create a bookmark, just fulfill the authentication form and then input `<CTRL+S>`; you'll then be asked to give a name to your bookmark, and tadah, the bookmark has been created.
|
||||
If you go to [gallery](#gallery-), there is a GIF showing how bookmarks work 💪.
|
||||
|
||||
### Are my passwords Safe 😈
|
||||
|
||||
Well, kinda.
|
||||
As said before, bookmarks are saved in your configuration directory along with passwords. Passwords are obviously not plain text, they are encrypted with **AES-128**. Does this make them safe? Well, depends on your operating system:
|
||||
|
||||
On Windows and MacOS the passwords are stored, if possible (but should be), in respectively the Windows Vault and the Keychain. This is actually super-safe and is directly managed by your operating system.
|
||||
|
||||
On Linux and BSD, on the other hand, the key used to encrypt your passwords is stored on your drive (at $HOME/.config/termscp). It is then, still possible to retrieve the key to decrypt passwords. Luckily, the location of the key guarantees your key can't be read by users different from yours, but yeah, I still wouldn't save the password for a server exposed on the internet 😉.
|
||||
Actually [keyring-rs](https://github.com/hwchen/keyring-rs), supports Linux, but for different reasons I preferred not to make it available for this configuration. If you want to read more about my decision read [this issue](https://github.com/veeso/termscp/issues/2), while if you think this might have been implemented differently feel free to open an issue with your proposal.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
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. [View more](#configuration-️)
|
||||
|
||||
---
|
||||
|
||||
## 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, since I made possible to configure termscp from its user interface entirely.
|
||||
|
||||
termscp, like for bookmarks, just requires to have these paths accessible:
|
||||
|
||||
- `$HOME/.config/termscp/` on Linux/BSD
|
||||
- `$HOME/Library/Application Support/termscp` on MacOs
|
||||
- `FOLDERID_RoamingAppData\termscp\` on Windows
|
||||
|
||||
To access configuration, you just have to press `<CTRL+C>` from the home of termscp.
|
||||
|
||||
These parameters can be changed:
|
||||
|
||||
- **Default Protocol**: the default protocol is the default value for the file transfer protocol to be used in termscp. This applies for the login page and for the address CLI argument.
|
||||
- **Text Editor**: the text editor to use. By default termscp will find the default editor for you; with this option you can force an editor to be used (e.g. `vim`). **Also GUI editors are supported**, unless they `nohup` from the parent process so if you ask: yes, you can use `notepad.exe`, and no: **Visual Studio Code doesn't work**.
|
||||
- **Show Hidden Files**: select whether hidden files shall be displayed by default. You will be able to decide whether to show or not hidden files at runtime pressing `A` anyway.
|
||||
- **Check for updates**: if set to `yes`, termscp will fetch the Github API to check if there is a new version of termscp available.
|
||||
- **Group Dirs**: select whether directories should be groupped or not in file explorers. If `Display first` is selected, directories will be sorted using the configured method but displayed before files, viceversa if `Display last` is selected.
|
||||
|
||||
### SSH Key Storage 🔐
|
||||
|
||||
Along with configuration, termscp provides also an **essential** feature for **SFTP/SCP clients**: the SSH key storage.
|
||||
|
||||
You can access the SSH key storage, from configuration moving to the `SSH Keys` tab, once there you can:
|
||||
|
||||
- **Add a new key**: just press `<CTRL+N>` and you will be prompted to create a new key. Provide the hostname/ip address and the username associated to the key and finally a text editor will open up: paste the **PRIVATE** ssh key into the text editor, save and quit.
|
||||
- **Remove an existing key**: just press `<DEL>` or `<CTRL+E>` on the key you want to remove, to delete persistently the key from termscp.
|
||||
- **Edit an existing key**: just press `<ENTER>` on the key you want to edit, to change the private key.
|
||||
|
||||
> Q: Wait, my private key is protected with password, can I use it?
|
||||
> A: Of course you can. The password provided for authentication in termscp, is valid both for username/password authentication and for RSA key authentication.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
- The key name is mandatory and must be one of the keys below
|
||||
- The length describes the length reserved to display the field. Static attributes doesn't support this (GROUP, PEX, SIZE, USER)
|
||||
- Extra is supported only by some parameters and is an additional options. See keys to check if extra is supported.
|
||||
|
||||
These are the keys supported by the formatter:
|
||||
|
||||
- `ATIME`: Last access time (with default syntax `%b %d %Y %H:%M`); Extra might be provided as the time syntax (e.g. `{ATIME:8:%H:%M}`)
|
||||
- `CTIME`: Creation time (with syntax `%b %d %Y %H:%M`); Extra might be provided as the time syntax (e.g. `{CTIME:8:%H:%M}`)
|
||||
- `GROUP`: Owner group
|
||||
- `MTIME`: Last change time (with syntax `%b %d %Y %H:%M`); Extra might be provided as the time syntax (e.g. `{MTIME:8:%H:%M}`)
|
||||
- `NAME`: File name (Elided if longer than 24)
|
||||
- `PEX`: File permissions (UNIX format)
|
||||
- `SIZE`: File size (omitted for directories)
|
||||
- `SYMLINK`: Symlink (if any `-> {FILE_PATH}`)
|
||||
- `USER`: Owner user
|
||||
|
||||
If left empty, the default formatter syntax will be used: `{NAME:24} {PEX} {USER} {SIZE} {MTIME:17:%b %d %Y %H:%M}`
|
||||
|
||||
---
|
||||
|
||||
## Keybindings ⌨
|
||||
|
||||
| Key | Command |
|
||||
|---------------|-------------------------------------------------------|
|
||||
| `<ESC>` | Disconnect from remote; return to authentication page |
|
||||
| `<TAB>` | Switch between log tab and explorer |
|
||||
| `<BACKSPACE>` | Go to previous directory in stack |
|
||||
| `<RIGHT>` | Move to remote explorer tab |
|
||||
| `<LEFT>` | Move to local explorer tab |
|
||||
| `<UP>` | Move up in selected list |
|
||||
| `<DOWN>` | Move down in selected list |
|
||||
| `<PGUP>` | Move up in selected list by 8 rows |
|
||||
| `<PGDOWN>` | Move down in selected list by 8 rows |
|
||||
| `<ENTER>` | Enter directory |
|
||||
| `<SPACE>` | Upload / download selected file |
|
||||
| `<D>` | Make directory |
|
||||
| `<G>` | Go to supplied path |
|
||||
| `<H>` | Show help |
|
||||
| `<H>` | Show info about selected file or directory |
|
||||
| `<Q>` | Quit TermSCP |
|
||||
| `<R>` | Rename file |
|
||||
| `<U>` | Go to parent directory |
|
||||
| `<CANC>` | Delete file |
|
||||
| Key | Command | Reminder |
|
||||
|---------------|-------------------------------------------------------|-------------|
|
||||
| `<ESC>` | Disconnect from remote; return to authentication page | |
|
||||
| `<TAB>` | Switch between log tab and explorer | |
|
||||
| `<BACKSPACE>` | Go to previous directory in stack | |
|
||||
| `<RIGHT>` | Move to remote explorer tab | |
|
||||
| `<LEFT>` | Move to local explorer tab | |
|
||||
| `<UP>` | Move up in selected list | |
|
||||
| `<DOWN>` | Move down in selected list | |
|
||||
| `<PGUP>` | Move up in selected list by 8 rows | |
|
||||
| `<PGDOWN>` | Move down in selected list by 8 rows | |
|
||||
| `<ENTER>` | Enter directory | |
|
||||
| `<SPACE>` | Upload / download selected file | |
|
||||
| `<A>` | Toggle hidden files | All |
|
||||
| `<B>` | Sort files by | Bubblesort? |
|
||||
| `<C>` | Copy file/directory | Copy |
|
||||
| `<D>` | Make directory | Directory |
|
||||
| `<E>` | Delete file (Same as `DEL`) | Erase |
|
||||
| `<F>` | Search for files (wild match is supported) | Find |
|
||||
| `<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 |
|
||||
| `<N>` | Create new file with provided name | New |
|
||||
| `<O>` | Edit file; see [Text editor](#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 |
|
||||
| `<DEL>` | Delete file | |
|
||||
| `<CTRL+C>` | Abort file transfer process | |
|
||||
|
||||
---
|
||||
|
||||
@@ -212,26 +376,36 @@ The developer documentation can be found on Rust Docs at <https://docs.rs/termsc
|
||||
|
||||
## Known issues 🧻
|
||||
|
||||
- Ftp:
|
||||
- Time in explorer is `1 Jan 1970`, but shouldn't be: that's because chrono can't parse date in a different locale. So if your server has a locale different from the one on your machine, it won't be able to parse the date.
|
||||
- Some servers don't work: yes, some kind of ftp server don't work correctly, sometimes it won't display any files in the directories, some other times uploading files will fail. Up to date, `vsftpd` is the only one server which I saw working correctly with TermSCP. Am I going to solve this? I'd like to, but it's not my fault at all. Unfortunately [rust-ftp](https://github.com/mattnenterprise/rust-ftp) is an abandoned project (up to 2020), indeed I had to patch many stuff by myself. I'll try to solve these issues, but it will take a long time.
|
||||
- Sftp:
|
||||
- sftp is much slower than scp: Okay this is an annoying issue, and again: not my fault. It seems there is an issue with [ssh2-rs](https://github.com/alexcrichton/ssh2-rs) library. If you want to stay up to date with the status of this issue, subscribe to [this issue](https://github.com/alexcrichton/ssh2-rs/issues/206)
|
||||
- `NoSuchFileOrDirectory` on connect: let me guess, you're running on WSL and you've installed termscp through cargo. 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`.
|
||||
- `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).
|
||||
|
||||
---
|
||||
|
||||
## Upcoming Features 🧪
|
||||
|
||||
- **File viewer**: possibility to show in a popup the file content from the explorer.
|
||||
- **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
|
||||
|
||||
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:
|
||||
|
||||
- Amazon S3 support
|
||||
- Samba support
|
||||
- Themes provider
|
||||
|
||||
---
|
||||
|
||||
## Contributions 🤙🏻
|
||||
## Contributing and issues 🤝🏻
|
||||
|
||||
Contributions are welcome! 😉
|
||||
Contributions, bug reports, new features and questions are welcome! 😉
|
||||
If you have any question or concern, or you want to suggest a new feature, or you want just want to improve termscp, feel free to open an issue or a PR.
|
||||
|
||||
If you think you can contribute to TermSCP, please follow [TermSCP's contributions guide](CONTRIBUTING.md)
|
||||
Please follow [our contributing guidelines](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## Changelog ⏳
|
||||
|
||||
@@ -245,7 +419,10 @@ TermSCP is powered by these aweseome projects:
|
||||
|
||||
- [bytesize](https://github.com/hyunsik/bytesize)
|
||||
- [crossterm](https://github.com/crossterm-rs/crossterm)
|
||||
- [edit](https://github.com/milkey-mouse/edit)
|
||||
- [keyring-rs](https://github.com/hwchen/keyring-rs)
|
||||
- [rpassword](https://github.com/conradkleinespel/rpassword)
|
||||
- [rust-ftp](https://github.com/mattnenterprise/rust-ftp)
|
||||
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
|
||||
- [textwrap](https://github.com/mgeisler/textwrap)
|
||||
- [tui-rs](https://github.com/fdehau/tui-rs)
|
||||
@@ -255,16 +432,36 @@ TermSCP is powered by these aweseome projects:
|
||||
|
||||
## Gallery 🎬
|
||||
|
||||
> Termscp Home
|
||||
|
||||

|
||||
|
||||
> Bookmarks
|
||||
|
||||

|
||||
|
||||
> Setup
|
||||
|
||||

|
||||
|
||||
> Text editor
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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 📃
|
||||
|
||||
Licensed under the GNU GPLv3 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
|
||||
|
||||
<http://www.gnu.org/licenses/gpl-3.0.txt>
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
||||
termscp is licensed under the MIT license since version 0.4.0.
|
||||
|
||||
You can read the entire license [HERE](LICENSE)
|
||||
|
||||
*Older version (0.1.0 ~ 0.3.3) are licensed under the [GPL-V3](http://www.gnu.org/licenses/gpl-3.0.txt) license*
|
||||
|
||||
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 212 KiB |
BIN
assets/images/bookmarks.gif
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
assets/images/config.gif
Normal file
|
After Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 632 KiB After Width: | Height: | Size: 504 KiB |
BIN
assets/images/text-editor.gif
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
7
codecov.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
ignore:
|
||||
- src/main.rs
|
||||
- src/lib.rs
|
||||
- src/activity_manager.rs
|
||||
- src/ui/activities/
|
||||
- src/ui/context.rs
|
||||
- src/ui/input.rs
|
||||
25
dist/build/README.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Build with Docker
|
||||
|
||||
- [Build with Docker](#build-with-docker)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Build](#build)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker
|
||||
|
||||
## Build
|
||||
|
||||
1. Build x86_64
|
||||
|
||||
this will build termscp for:
|
||||
|
||||
- Linux x86_64 Deb packages
|
||||
- Linux x86_64 RPM packages
|
||||
- Windows x86_64 MSVC packages
|
||||
|
||||
```sh
|
||||
|
||||
```
|
||||
47
dist/build/deploy.sh
vendored
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: deploy.sh <version>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=$1
|
||||
|
||||
set -e # Don't fail
|
||||
|
||||
# Create pkgs directory
|
||||
cd ..
|
||||
PKGS_DIR=$(pwd)/pkgs
|
||||
cd -
|
||||
mkdir -p ${PKGS_DIR}/
|
||||
# Build x86_64_deb
|
||||
cd x86_64_debian9/
|
||||
docker build --tag termscp-${VERSION}-x86_64_debian9 .
|
||||
cd -
|
||||
mkdir -p ${PKGS_DIR}/deb/
|
||||
CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_debian9 termscp-${VERSION}-x86_64_debian9)
|
||||
docker cp ${CONTAINER_NAME}:/usr/src/termscp/target/debian/termscp_${VERSION}_amd64.deb ${PKGS_DIR}/deb/
|
||||
# Build x86_64_centos7
|
||||
cd x86_64_centos7/
|
||||
docker build --tag termscp-${VERSION}-x86_64_centos7 .
|
||||
cd -
|
||||
mkdir -p ${PKGS_DIR}/rpm/
|
||||
CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_centos7 termscp-${VERSION}-x86_64_centos7)
|
||||
docker cp ${CONTAINER_NAME}:/usr/src/termscp/target/release/rpmbuild/RPMS/x86_64/termscp-${VERSION}-1.el7.x86_64.rpm ${PKGS_DIR}/rpm/termscp-${VERSION}-1.x86_64.rpm
|
||||
# Build x86_64_archlinux
|
||||
|
||||
##################### TEMP REMOVED ###################################
|
||||
# cd x86_64_archlinux/
|
||||
# docker build --tag termscp-${VERSION}-x86_64_archlinux .
|
||||
# # Create container and get AUR pkg
|
||||
# cd -
|
||||
# mkdir -p ${PKGS_DIR}/arch/
|
||||
# CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_archlinux termscp-${VERSION}-x86_64_archlinux)
|
||||
# docker cp ${CONTAINER_NAME}:/usr/src/termscp/termscp-${VERSION}-x86_64.tar.gz ${PKGS_DIR}/arch/
|
||||
# docker cp ${CONTAINER_NAME}:/usr/src/termscp/PKGBUILD ${PKGS_DIR}/arch/
|
||||
# docker cp ${CONTAINER_NAME}:/usr/src/termscp/.SRCINFO ${PKGS_DIR}/arch/
|
||||
# # Replace termscp-bin with termscp in PKGBUILD
|
||||
# sed -i 's/termscp-bin/termscp/g' ${PKGS_DIR}/arch/PKGBUILD
|
||||
##################### TEMP REMOVED ###################################
|
||||
|
||||
exit $?
|
||||
19
dist/build/x86_64/Dockerfile
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM rust:1.48.0 AS builder
|
||||
|
||||
WORKDIR /usr/src/
|
||||
# Add toolchains
|
||||
RUN rustup target add x86_64-unknown-linux-gnu
|
||||
# Install dependencies
|
||||
RUN apt update && apt install -y rpm
|
||||
# Clone repository
|
||||
RUN git clone https://github.com/veeso/termscp.git
|
||||
# Set workdir to termscp
|
||||
WORKDIR /usr/src/termscp/
|
||||
# Install cargo RPM/Deb
|
||||
RUN cargo install cargo-deb cargo-rpm
|
||||
# Build for x86_64
|
||||
RUN cargo build --release --target x86_64-unknown-linux-gnu
|
||||
# Build pkgs
|
||||
RUN cargo deb && cargo rpm init && cargo rpm build
|
||||
|
||||
CMD ["sh"]
|
||||
33
dist/build/x86_64_archlinux/Dockerfile
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM archlinux:base-20210120.0.13969 as builder
|
||||
|
||||
WORKDIR /usr/src/
|
||||
# Install dependencies
|
||||
RUN pacman -Syu --noconfirm \
|
||||
git \
|
||||
gcc \
|
||||
openssl \
|
||||
pkg-config \
|
||||
sudo
|
||||
# Install rust
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
|
||||
chmod +x /tmp/rust.sh && \
|
||||
/tmp/rust.sh -y
|
||||
# Create build user
|
||||
RUN useradd build -m && \
|
||||
passwd -d build && \
|
||||
mkdir -p termscp && \
|
||||
chown -R build.build termscp/
|
||||
# Clone repository
|
||||
RUN git clone https://github.com/veeso/termscp.git
|
||||
# Set workdir to termscp
|
||||
WORKDIR /usr/src/termscp/
|
||||
# Install cargo arxch
|
||||
RUN source $HOME/.cargo/env && cargo install cargo-aur
|
||||
# Build for x86_64
|
||||
RUN source $HOME/.cargo/env && cargo build --release
|
||||
# Build pkgs
|
||||
RUN source $HOME/.cargo/env && cargo aur
|
||||
# Create SRCINFO
|
||||
RUN chown -R build.build ../termscp/ && sudo -u build bash -c 'makepkg --printsrcinfo > .SRCINFO'
|
||||
|
||||
CMD ["sh"]
|
||||
25
dist/build/x86_64_centos7/Dockerfile
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM centos:centos7 as builder
|
||||
|
||||
WORKDIR /usr/src/
|
||||
# Install dependencies
|
||||
RUN yum -y install \
|
||||
git \
|
||||
gcc \
|
||||
openssl \
|
||||
pkgconfig \
|
||||
openssl-devel
|
||||
# Install rust
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
|
||||
chmod +x /tmp/rust.sh && \
|
||||
/tmp/rust.sh -y
|
||||
# Clone repository
|
||||
RUN git clone https://github.com/veeso/termscp.git
|
||||
# Set workdir to termscp
|
||||
WORKDIR /usr/src/termscp/
|
||||
# Install cargo arxch
|
||||
RUN source $HOME/.cargo/env && cargo install cargo-rpm
|
||||
# Build for x86_64
|
||||
RUN source $HOME/.cargo/env && cargo build --release
|
||||
# Build pkgs
|
||||
RUN source $HOME/.cargo/env && yum -y install rpm-build && cargo rpm init && cargo rpm build
|
||||
CMD ["sh"]
|
||||
28
dist/build/x86_64_debian8/Dockerfile
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM debian:jessie
|
||||
|
||||
WORKDIR /usr/src/
|
||||
# Install dependencies
|
||||
RUN apt update && apt install -y \
|
||||
git \
|
||||
gcc \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libssh2-1-dev \
|
||||
curl
|
||||
|
||||
# Install rust
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
|
||||
chmod +x /tmp/rust.sh && \
|
||||
/tmp/rust.sh -y
|
||||
# Clone repository
|
||||
RUN git clone https://github.com/veeso/termscp.git
|
||||
# Set workdir to termscp
|
||||
WORKDIR /usr/src/termscp/
|
||||
# Install cargo deb
|
||||
RUN . $HOME/.cargo/env && cargo install cargo-deb
|
||||
# Build for x86_64
|
||||
RUN . $HOME/.cargo/env && cargo build --release
|
||||
# Build pkgs
|
||||
RUN . $HOME/.cargo/env && cargo deb
|
||||
|
||||
CMD ["sh"]
|
||||
28
dist/build/x86_64_debian9/Dockerfile
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM debian:stretch
|
||||
|
||||
WORKDIR /usr/src/
|
||||
# Install dependencies
|
||||
RUN apt update && apt install -y \
|
||||
git \
|
||||
gcc \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libssh2-1-dev \
|
||||
curl
|
||||
|
||||
# Install rust
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
|
||||
chmod +x /tmp/rust.sh && \
|
||||
/tmp/rust.sh -y
|
||||
# Clone repository
|
||||
RUN git clone https://github.com/veeso/termscp.git
|
||||
# Set workdir to termscp
|
||||
WORKDIR /usr/src/termscp/
|
||||
# Install cargo deb
|
||||
RUN . $HOME/.cargo/env && cargo install cargo-deb
|
||||
# Build for x86_64
|
||||
RUN . $HOME/.cargo/env && cargo build --release
|
||||
# Build pkgs
|
||||
RUN . $HOME/.cargo/env && cargo deb
|
||||
|
||||
CMD ["sh"]
|
||||
14
dist/pkgs/arch/.SRCINFO
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
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
|
||||
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
|
||||
sha256sums = c72f78a4707402f7f970a883899f4f1583fd9eca6166cb7f7616be97cabf768a
|
||||
|
||||
pkgname = termscp
|
||||
|
||||
16
dist/pkgs/arch/PKGBUILD
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Maintainer: Christian Visintin
|
||||
pkgname=termscp
|
||||
pkgver=0.4.2
|
||||
pkgrel=1
|
||||
pkgdesc="TermSCP is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal."
|
||||
url="https://github.com/veeso/termscp"
|
||||
license=("MIT")
|
||||
arch=("x86_64")
|
||||
provides=("termscp")
|
||||
options=("strip")
|
||||
source=("https://github.com/veeso/termscp/releases/download/v$pkgver/termscp-$pkgver-x86_64.tar.gz")
|
||||
sha256sums=("c72f78a4707402f7f970a883899f4f1583fd9eca6166cb7f7616be97cabf768a")
|
||||
|
||||
package() {
|
||||
install -Dm755 termscp -t "$pkgdir/usr/bin/"
|
||||
}
|
||||
35
docs/deploy.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Deploy checklist
|
||||
|
||||
Document audience: project maintainers
|
||||
|
||||
- [Deploy checklist](#deploy-checklist)
|
||||
- [Description](#description)
|
||||
- [Checklist](#checklist)
|
||||
|
||||
## Description
|
||||
|
||||
This document describes the checklist that must be fulfilled before releasing a new version of termscp.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] The latest build didn't report any error in the CI
|
||||
- [ ] All commands when using SFTP work
|
||||
- [ ] All commands when using SCP work
|
||||
- [ ] All commands when using FTP work
|
||||
- [ ] It is possible to load bookmarks
|
||||
- [ ] Recent connections get saved
|
||||
- [ ] Update versions and release date in readme, changelog and cargo.toml
|
||||
- [ ] Build on MacOS
|
||||
- [ ] Update sha256 and version on homebrew repository
|
||||
- [ ] Build on Windows
|
||||
- [ ] Update sha256 and version in chocolatey repository
|
||||
- [ ] Create chocolatey package
|
||||
- [ ] Build Linux version using docker from `dist/build/build.sh`
|
||||
- [ ] Update sha256 and version in AUR files
|
||||
- [ ] Create release and attach the following artifacts
|
||||
- [ ] Deb package
|
||||
- [ ] RPM package
|
||||
- [ ] MacOs tar.gz
|
||||
- [ ] Windows nupkg
|
||||
- [ ] Windows zip
|
||||
- [ ] AUR tar.gz
|
||||
206
docs/developer.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Developer Manual
|
||||
|
||||
Document audience: developers
|
||||
|
||||
- [Developer Manual](#developer-manual)
|
||||
- [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.
|
||||
|
||||
## How TermSCP works
|
||||
|
||||
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.
|
||||
- the **ui**: this module contains the implementation of the user interface, as we'll see in the next chapter, this is achieved through **activities**.
|
||||
- the **activity_manager**: the activity manager takes care of managing activities, basically it runs the activities of the user interface, and chooses, based on their state, when is the moment to terminate the current activity and which activity to run after the current one.
|
||||
|
||||
In addition to the 4 main components, other have been added through the time:
|
||||
|
||||
- **config**: this module provides the configuration schema and serialization methods for it.
|
||||
- **fs**: this modules exposes the FsEntry entity and the explorers. The explorers are structs which hold the content of the current directory; they also they take of filtering files up to your preferences and format file entries based on your configuration.
|
||||
- **system**: the system module provides a way to actually interact with the configuration, the ssh key storage and with the bookmarks.
|
||||
- **utils**: contains the utilities used by pretty much all the project.
|
||||
|
||||
## Activities
|
||||
|
||||
Just a little paragraph about activities. Really, read the code and the documentation to have a clear idea of how the ui works.
|
||||
I think there are many ways to implement a user interface and I've worked with different languages and frameworks in my career, so for this project I've decided to get what I like the most from different frameworks to implement it.
|
||||
|
||||
My approach was this:
|
||||
|
||||
- **Activities on top**: each "page" is an Activity and an `Activity Manager` handles them. I got inspired by Android for this case. I think that's a good way to implement the ui in case like this, where you have different pages, each one with their view, their components and their logics. Activities work with the `Context`, which is a data holder for different data, which are shared and common between the activities.
|
||||
- **Activities display Views**: Each activity can show different views. A view is basically a list of **components**, each one with its properties. The view is a facade to the components and also handles the focus, which is the current active component. You cannot have more than one component active, so you need to handle this; but at the same time you also have to give focus to the previously active component if the current one is destroyed. So basically view takes care of all this stuff.
|
||||
- **Components**: I've decided to write around `tui` in order to re-use widgets. To do so I've implemented the `Component` trait. To implement traits I got inspired by [React](https://reactjs.org/). Each component has its *Properties* and can have its *States*. Then each component must be able to handle input events and to be updated with new properties. Last but not least, each component must provide a method to **render** itself.
|
||||
- **Messages: an Elm based approach**: I was really satisfied with my implementation choices; the problem at this point was solving one of the biggest teardrops I've ever had with this project: **events**. Input events were really a pain to handle, since I had to handle states in the activity to handle which component was enabled etc. To solve this I got inspired by a wonderful language I had recently studied, which is [Elm](https://elm-lang.org/). Basically in Elm you implement your ui using three basic functions: **update**, **view** and **init**. View and init were pretty much already implemented here, but at this point I decided to implement also something like the **elm update function**. I came out with a huge match case to handle events inside a recursive function, which you can basically find in the `update.rs` file inside each activity. This match case handles a tuple, made out of the **component id** and the **input event** received from the view. It matches the two propeties against the input event we want to handle for each component *et voilà*.
|
||||
|
||||
I've implemented a Trait called `Activity`, which, is a very very reduced version of the Android activity of course.
|
||||
This trait provides only 3 methods:
|
||||
|
||||
- `on_create`: this method must initialize the activity; the context is passed to the activity, which will be the only owner of the Context, until the activity terminates.
|
||||
- `on_draw`: this method must be called each time you want to perform an update of the user interface. This is basically the run method of the activity. This method also cares about handling input events. The developer shouldn't draw the interface on each call of this method (consider that this method might be called hundreds of times per second), but only when actually something has changed (for example after an input event has been raised).
|
||||
- `will_umount`: this method was added in 0.4.0 and returns whethere the activity should be destroyed. If so returns an ExitReason, which indicates why the activity should be terminated. Based on the reason, the activity manager chooses whether to stop the execution of termscp or to start a new activity and which one.
|
||||
- `on_destroy`: this method finalizes the activity and drops it; this method returns the Context to the caller (the activity manager).
|
||||
|
||||
### The Context
|
||||
|
||||
The context is a structure which holds data which must be shared between activities. Everytime an Activity starts, the Context is taken by the activity, until it is destroyed, where finally the context is returned to the activity manager.
|
||||
The context basically holds the following data:
|
||||
|
||||
- The **Localhost**: the local host structure
|
||||
- The **File Transfer Params**: the current parameters set to connect to the remote
|
||||
- The **Config Client**: the configuration client is a structure which provides functions to access the user configuration
|
||||
- The **Store**: the store is a key-value storage which can hold any kind of data. This can be used to store states to share between activities or to keep persistence for heavy/slow tasks (such as checking for updates).
|
||||
- The **Input handler**: the input handler is used to read input events from the keyboard
|
||||
- The **Terminal**: the terminal is used to view the tui on the terminal
|
||||
|
||||
---
|
||||
|
||||
## Tests fails due to receivers
|
||||
|
||||
Yes. This happens quite often and is related to the fact that I'm using public SSH/SFTP/FTP server to test file receivers and sometimes this server go down for even a day or more. If your tests don't pass due to this, don't worry, submit the pull request and I'll take care of testing them by myself.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
|
||||
1. Add the Scp protocol to the `FileTransferProtocol` enum.
|
||||
|
||||
Move to `src/filetransfer/mod.rs` and add `Scp` to the `FileTransferProtocol` enum
|
||||
|
||||
```rs
|
||||
/// ## FileTransferProtocol
|
||||
///
|
||||
/// This enum defines the different transfer protocol available in TermSCP
|
||||
#[derive(std::cmp::PartialEq, std::fmt::Debug, std::clone::Clone)]
|
||||
pub enum FileTransferProtocol {
|
||||
Sftp,
|
||||
Ftp(bool), // Bool is for secure (true => ftps)
|
||||
Scp, // <-- here
|
||||
}
|
||||
```
|
||||
|
||||
In this case Scp is a "plain" enum type. If you need particular options, follow the implementation of `Ftp` which uses a boolean flag for indicating if using FTPS or FTP.
|
||||
|
||||
2. Implement the FileTransfer struct
|
||||
|
||||
Create a file at `src/filetransfer/mytransfer.rs`
|
||||
|
||||
Declare your file transfer struct
|
||||
|
||||
```rs
|
||||
/// ## ScpFileTransfer
|
||||
///
|
||||
/// SFTP file transfer structure
|
||||
pub struct ScpFileTransfer {
|
||||
session: Option<Session>,
|
||||
sftp: Option<Sftp>,
|
||||
wrkdir: PathBuf,
|
||||
}
|
||||
```
|
||||
|
||||
3. Implement the `FileTransfer` trait for it
|
||||
|
||||
You'll have to implement the following methods for your file transfer:
|
||||
|
||||
- connect: connect to remote server
|
||||
- disconnect: disconnect from remote server
|
||||
- is_connected: returns whether the file transfer is connected to remote
|
||||
- pwd: get working directory
|
||||
- change_dir: change working directory.
|
||||
- list_dir: get files and directories at a certain path
|
||||
- mkdir: make a new directory. Return an error in case the directory already exists
|
||||
- remove: remove a file or a directory. In case the protocol doesn't support recursive removing of directories you MUST implement this through a recursive algorithm
|
||||
- rename: rename a file or a directory
|
||||
- stat: returns detail for a certain path
|
||||
- send_file: opens a stream to a remote path for write purposes (write a remote file)
|
||||
- recv_file: opens a stream to a remote path for read purposes (write a local file)
|
||||
- on_sent: finalize a stream when writing a remote file. In case it's not necessary just return `Ok(())`
|
||||
- on_recv: fianlize a stream when reading a remote file. In case it's not necessary just return `Ok(())`
|
||||
|
||||
In case the protocol you're working on doesn't support any of this features, just return `Err(FileTransferError::new(FileTransferErrorType::UnsupportedFeature))`
|
||||
|
||||
4. Add your transfer to filetransfers:
|
||||
|
||||
Move to `src/filetransfer/mod.rs` and declare your file transfer:
|
||||
|
||||
```rs
|
||||
// Transfers
|
||||
pub mod ftp_transfer;
|
||||
pub mod scp_transfer; // <-- here
|
||||
pub mod sftp_transfer;
|
||||
```
|
||||
|
||||
5. Handle FileTransfer in `FileTransferActivity::new`
|
||||
|
||||
Move to `src/ui/activities/filetransfer_activity/mod.rs` and add the new protocol to the client match
|
||||
|
||||
```rs
|
||||
client: match protocol {
|
||||
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new()),
|
||||
FileTransferProtocol::Ftp(ftps) => Box::new(FtpFileTransfer::new(ftps)),
|
||||
FileTransferProtocol::Scp => Box::new(ScpFileTransfer::new()), // <--- here
|
||||
},
|
||||
```
|
||||
|
||||
6. Handle right/left input events in `AuthActivity`:
|
||||
|
||||
Move to `src/ui/activities/auth_activity.rs` and handle the new protocol in `handle_input_event_mode_text` for `KeyCode::Left` and `KeyCode::Right`.
|
||||
Consider that the order they "rotate" must match the way they will be drawned in the interface.
|
||||
For newer protocols, please put them always at the end of the list. In this list I won't, because Scp is more important than Ftp imo.
|
||||
|
||||
```rs
|
||||
KeyCode::Left => {
|
||||
// If current field is Protocol handle event... (move element left)
|
||||
if self.selected_field == InputField::Protocol {
|
||||
self.protocol = match self.protocol {
|
||||
FileTransferProtocol::Sftp => FileTransferProtocol::Ftp(true), // End of list (wrap)
|
||||
FileTransferProtocol::Scp => FileTransferProtocol::Sftp,
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => FileTransferProtocol::Scp,
|
||||
true => FileTransferProtocol::Ftp(false),
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
// If current field is Protocol handle event... ( move element right )
|
||||
if self.selected_field == InputField::Protocol {
|
||||
self.protocol = match self.protocol {
|
||||
FileTransferProtocol::Sftp => FileTransferProtocol::Scp,
|
||||
FileTransferProtocol::Scp => FileTransferProtocol::Ftp(false),
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => FileTransferProtocol::Ftp(true),
|
||||
true => FileTransferProtocol::Sftp, // End of list (wrap)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. Add your new file transfer to the protocol input field
|
||||
|
||||
Move to `AuthActivity::draw_protocol_select` method.
|
||||
Here add your new protocol to the `Spans` vector and to the match case, which chooses which element to highlight.
|
||||
|
||||
```rs
|
||||
let protocols: Vec<Spans> = vec![Spans::from("SFTP"), Spans::from("SCP"), Spans::from("FTP"), Spans::from("FTPS")];
|
||||
let index: usize = match self.protocol {
|
||||
FileTransferProtocol::Sftp => 0,
|
||||
FileTransferProtocol::Scp => 1,
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => 2,
|
||||
true => 3,
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
<mxfile host="Electron" modified="2020-11-21T19:08:13.709Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/13.3.1 Chrome/83.0.4103.119 Electron/9.0.5 Safari/537.36" etag="CH09h_mpCNwCuwQp3CzT" version="13.3.1" type="device" pages="2"><diagram id="SlpWaeKyUTlHltKKkHdB" name="ScpActivity">7ZlRk5owEMc/DY/HABGEx2rv2pnWmXaczj3nJEKmgdAQD+yn7wJBoOhUrbbcTHwx2SSb5L+/4EoMtEzKDwJn8YqHhBmOFZYGem84ju26FnxVln1j8QO3MUSChqpTZ1jTn0QZ1bhoR0OSDzpKzpmk2dC44WlKNnJgw0LwYthty9lw1gxHZGRYbzAbW59pKGO1C2fe2T8SGsXtzLYXNC0JbjurneQxDnnRM6FHAy0F57IpJeWSsEq8Vpdm3NOJ1sPCBEnlOQOyRbEsv31ixSv+vi6c588r9vXBaby8YrZTG1aLlftWAcF3aUgqJ5aBFkVMJVlneFO1FhBzsMUyYVCzobiljC054wLqKU+h00LNQIQk5cml2wdBgCTCEyLFHrqoAchEqBmzH9JRdCFBgWd6KipxLyJopvpiRUJ0cN+JBQWl1wXaoTeh3azl77R0M8s6pptj+t6dpJsdkc5jMPPiBQqRrLfeGLYc9tkX1fux423DQ14/MN5BB9Ck7BpbL1+qU6g8wUobZ8MJwNyb9MYBxIxGKVQZ2crbxBMo9/58Fo4E1L3XOXB1MK8/nO60YumNYvlEGUlxQkBVnFQapS95Vu/e+semGo8b+seS3nZbGREJzXPK0/w6H7xIiahTIb7L3gC+o2fR/Bi/8Os9GyPs3QvhuUZYI3x1ejQFgn1NsCb4fILNwA96H3+YUaAJAB1ooDXQl2QVbh/o+RDoYAJAt3m6JloT/ddEH/7N/VeibU20JvqStNm0+p/f3klNIeuwxy+3NdIa6bNflE8i0RhfMmiGNcOnGbaHqcYUM43x5c+K5Hl1D+tYL7y8m8wbEBGi2Vw2rJV/u603N8+Oe6NAmEEwkB55Y+l92zUteyz94clzgfZQ7a6S67behTx6/AU=</diagram><diagram id="SK1VvSCf6-f5suE94Ksw" name="AuthActivity">5ZfRbpswFIafhstIAQNtL1OarlI1qUq6RdrNZLADnowPs00ge/qZYCA0ldZOSiqVK+z/2Mec/zuWwEFRXn+RuMi+AqHc8eakdtCd43luEMzNo1H2rXJ9E7RCKhmxiwZhzf5QK9p9ackIVaOFGoBrVozFBISgiR5pWEqoxsu2wMenFjilJ8I6wfxU3TCiM1uFdzXoD5SlWXeyG960kRx3i20lKsMEqiMJLR0USQDdjvI6orwxr/Pl8ennZvUQzVZ3uwXDz8pfPaNZm+z+PVv6EiQV+r9Tb/ax+q59lCz5D/5rN4sf0z71DvPS+mVr1fvOQAmlILRJMnfQbZUxTdcFTppoZVrGaJnOuZm5ZrhlnEfAQZq5AEEbCYS2beEFZo45S4WZcLo1xdzaF6BS0/oFsX+U6/YMTPNSyKmWe7PP9qlvqVXH0K2WHQFHXaNi22hpn2ow0wysn+/w1jvxdlHqzNTEEqwZiKk5HZ7LaPRKE4dcW0dGHoe/S+gCM3XwamEWuH5RD0EzSu3zkCXuhAUhkip12NOGzPvGL5cbrT23kz8HZnf+Rs7+uTj7l+L8BFJPFPJbL/PZIAcXgyxBQwK83aQob74+Jkj8+qOJh5ci/k1RKXBOp3m1++/YDwN9dbGrjZWqQJIWNM4bJiJWxRjyZMCH5wNvpsMP0CF29BuJln8B</diagram></mxfile>
|
||||
@@ -2,40 +2,42 @@
|
||||
//!
|
||||
//! `activity_manager` is the module which provides run methods and handling for activities
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Deps
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::host::Localhost;
|
||||
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, filetransfer_activity::FileTransferParams,
|
||||
Activity,
|
||||
auth_activity::AuthActivity, filetransfer_activity::FileTransferActivity,
|
||||
setup_activity::SetupActivity, Activity, ExitReason,
|
||||
};
|
||||
use crate::ui::context::Context;
|
||||
use crate::ui::context::{Context, FileTransferParams};
|
||||
|
||||
// Namespaces
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -45,6 +47,7 @@ use std::time::Duration;
|
||||
pub enum NextActivity {
|
||||
Authentication,
|
||||
FileTransfer,
|
||||
SetupActivity,
|
||||
}
|
||||
|
||||
/// ### ActivityManager
|
||||
@@ -52,7 +55,6 @@ pub enum NextActivity {
|
||||
/// The activity manager takes care of running activities and handling them until the application has ended
|
||||
pub struct ActivityManager {
|
||||
context: Option<Context>,
|
||||
ftparams: Option<FileTransferParams>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
@@ -60,20 +62,22 @@ impl ActivityManager {
|
||||
/// ### new
|
||||
///
|
||||
/// Initializes a new Activity Manager
|
||||
pub fn new(
|
||||
local_dir: &PathBuf,
|
||||
interval: Duration,
|
||||
) -> Result<ActivityManager, ()> {
|
||||
pub fn new(local_dir: &Path, interval: Duration) -> Result<ActivityManager, HostError> {
|
||||
// Prepare Context
|
||||
let host: Localhost = match Localhost::new(local_dir.clone()) {
|
||||
let host: Localhost = match Localhost::new(local_dir.to_path_buf()) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Err(()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let ctx: Context = Context::new(host);
|
||||
// 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)),
|
||||
};
|
||||
let ctx: Context = Context::new(host, config_client, error);
|
||||
Ok(ActivityManager {
|
||||
context: Some(ctx),
|
||||
ftparams: None,
|
||||
interval: interval,
|
||||
interval,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -87,13 +91,16 @@ impl ActivityManager {
|
||||
protocol: FileTransferProtocol,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
entry_directory: Option<PathBuf>,
|
||||
) {
|
||||
self.ftparams = Some(FileTransferParams {
|
||||
address: address,
|
||||
port: port,
|
||||
protocol: protocol,
|
||||
username: username,
|
||||
password: password,
|
||||
// Put params into the context
|
||||
self.context.as_mut().unwrap().ft_params = Some(FileTransferParams {
|
||||
address,
|
||||
port,
|
||||
protocol,
|
||||
username,
|
||||
password,
|
||||
entry_directory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,6 +116,7 @@ impl ActivityManager {
|
||||
Some(activity) => match activity {
|
||||
NextActivity::Authentication => self.run_authentication(),
|
||||
NextActivity::FileTransfer => self.run_filetransfer(),
|
||||
NextActivity::SetupActivity => self.run_setup(),
|
||||
},
|
||||
None => break, // Exit
|
||||
}
|
||||
@@ -117,7 +125,7 @@ impl ActivityManager {
|
||||
drop(self.context.take());
|
||||
}
|
||||
|
||||
// Loops
|
||||
// -- Activity Loops
|
||||
|
||||
/// ### run_authentication
|
||||
///
|
||||
@@ -126,13 +134,13 @@ impl ActivityManager {
|
||||
/// Returns the next activity to run
|
||||
fn run_authentication(&mut self) -> Option<NextActivity> {
|
||||
// Prepare activity
|
||||
let mut activity: AuthActivity = AuthActivity::new();
|
||||
let mut activity: AuthActivity = AuthActivity::default();
|
||||
// Prepare result
|
||||
let result: Option<NextActivity>;
|
||||
// Get context
|
||||
let ctx: Context = match self.context.take() {
|
||||
Some(ctx) => ctx,
|
||||
None => return None
|
||||
None => return None,
|
||||
};
|
||||
// Create activity
|
||||
activity.on_create(ctx);
|
||||
@@ -140,29 +148,24 @@ impl ActivityManager {
|
||||
// Draw activity
|
||||
activity.on_draw();
|
||||
// Check if has to be terminated
|
||||
if activity.quit {
|
||||
// Quit activities
|
||||
result = None;
|
||||
break;
|
||||
}
|
||||
if activity.submit {
|
||||
// User submitted, set next activity
|
||||
result = Some(NextActivity::FileTransfer);
|
||||
// Get params
|
||||
self.ftparams = Some(FileTransferParams {
|
||||
address: activity.address.clone(),
|
||||
port: activity.port.parse::<u16>().ok().unwrap(),
|
||||
username: match activity.username.len() {
|
||||
0 => None,
|
||||
_ => Some(activity.username.clone()),
|
||||
},
|
||||
password: match activity.password.len() {
|
||||
0 => None,
|
||||
_ => Some(activity.password.clone()),
|
||||
},
|
||||
protocol: activity.protocol.clone(),
|
||||
});
|
||||
break;
|
||||
if let Some(exit_reason) = activity.will_umount() {
|
||||
match exit_reason {
|
||||
ExitReason::Quit => {
|
||||
result = None;
|
||||
break;
|
||||
}
|
||||
ExitReason::EnterSetup => {
|
||||
// User requested activity
|
||||
result = Some(NextActivity::SetupActivity);
|
||||
break;
|
||||
}
|
||||
ExitReason::Connect => {
|
||||
// User submitted, set next activity
|
||||
result = Some(NextActivity::FileTransfer);
|
||||
break;
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
// Sleep for ticks
|
||||
sleep(self.interval);
|
||||
@@ -178,34 +181,40 @@ impl ActivityManager {
|
||||
/// Returns when activity terminates.
|
||||
/// Returns the next activity to run
|
||||
fn run_filetransfer(&mut self) -> Option<NextActivity> {
|
||||
if self.ftparams.is_none() {
|
||||
return Some(NextActivity::Authentication);
|
||||
}
|
||||
// Prepare activity
|
||||
let mut activity: FileTransferActivity =
|
||||
FileTransferActivity::new(self.ftparams.take().unwrap());
|
||||
// Prepare result
|
||||
let result: Option<NextActivity>;
|
||||
// Get context
|
||||
let ctx: Context = match self.context.take() {
|
||||
Some(ctx) => ctx,
|
||||
None => return None
|
||||
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,
|
||||
};
|
||||
// Prepare activity
|
||||
let protocol: FileTransferProtocol = ft_params.protocol;
|
||||
let mut activity: FileTransferActivity = FileTransferActivity::new(protocol);
|
||||
// Prepare result
|
||||
let result: Option<NextActivity>;
|
||||
// Create activity
|
||||
activity.on_create(ctx);
|
||||
loop {
|
||||
// Draw activity
|
||||
activity.on_draw();
|
||||
// Check if has to be terminated
|
||||
if activity.quit {
|
||||
// Quit activities
|
||||
result = None;
|
||||
break;
|
||||
}
|
||||
if activity.disconnected {
|
||||
// User disconnected, set next activity to authentication
|
||||
result = Some(NextActivity::Authentication);
|
||||
break;
|
||||
if let Some(exit_reason) = activity.will_umount() {
|
||||
match exit_reason {
|
||||
ExitReason::Quit => {
|
||||
result = None;
|
||||
break;
|
||||
}
|
||||
ExitReason::Disconnect => {
|
||||
// User disconnected, set next activity to authentication
|
||||
result = Some(NextActivity::Authentication);
|
||||
break;
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
// Sleep for ticks
|
||||
sleep(self.interval);
|
||||
@@ -214,4 +223,66 @@ impl ActivityManager {
|
||||
self.context = activity.on_destroy();
|
||||
result
|
||||
}
|
||||
|
||||
/// ### run_setup
|
||||
///
|
||||
/// `SetupActivity` run loop.
|
||||
/// Returns when activity terminates.
|
||||
/// Returns the next activity to run
|
||||
fn run_setup(&mut self) -> Option<NextActivity> {
|
||||
// Prepare activity
|
||||
let mut activity: SetupActivity = SetupActivity::default();
|
||||
// Get context
|
||||
let ctx: Context = match self.context.take() {
|
||||
Some(ctx) => ctx,
|
||||
None => return None,
|
||||
};
|
||||
// Create activity
|
||||
activity.on_create(ctx);
|
||||
loop {
|
||||
// Draw activity
|
||||
activity.on_draw();
|
||||
// Check if activity has terminated
|
||||
if let Some(ExitReason::Quit) = activity.will_umount() {
|
||||
break;
|
||||
}
|
||||
// Sleep for ticks
|
||||
sleep(self.interval);
|
||||
}
|
||||
// Destroy activity
|
||||
self.context = activity.on_destroy();
|
||||
// This activity always returns to AuthActivity
|
||||
Some(NextActivity::Authentication)
|
||||
}
|
||||
|
||||
// -- misc
|
||||
|
||||
/// ### init_config_client
|
||||
///
|
||||
/// Initialize configuration client
|
||||
fn init_config_client() -> Result<ConfigClient, String> {
|
||||
// Get config dir
|
||||
match environment::init_config_dir() {
|
||||
Ok(config_dir) => {
|
||||
match config_dir {
|
||||
Some(config_dir) => {
|
||||
// Get config client paths
|
||||
let (config_path, ssh_dir): (PathBuf, PathBuf) =
|
||||
environment::get_config_paths(config_dir.as_path());
|
||||
match ConfigClient::new(config_path.as_path(), ssh_dir.as_path()) {
|
||||
Ok(cli) => Ok(cli),
|
||||
Err(err) => Err(format!("Could not read configuration: {}", err)),
|
||||
}
|
||||
}
|
||||
None => Err(String::from(
|
||||
"Your system doesn't support configuration paths",
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(format!(
|
||||
"Could not initialize configuration directory: {}",
|
||||
err
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
196
src/bookmarks/mod.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
//! ## Bookmarks
|
||||
//!
|
||||
//! `bookmarks` is the module which provides data types and de/serializer for bookmarks
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
pub mod serializer;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Deserialize, Serialize, std::fmt::Debug)]
|
||||
/// ## UserHosts
|
||||
///
|
||||
/// UserHosts contains all the hosts saved by the user in the data storage
|
||||
/// It contains both `Bookmark`
|
||||
pub struct UserHosts {
|
||||
pub bookmarks: HashMap<String, Bookmark>,
|
||||
pub recents: HashMap<String, Bookmark>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, std::fmt::Debug, PartialEq)]
|
||||
/// ## Bookmark
|
||||
///
|
||||
/// Bookmark describes a single bookmark entry in the user hosts storage
|
||||
pub struct Bookmark {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub protocol: String,
|
||||
pub username: String,
|
||||
pub password: Option<String>, // Password is optional; base64, aes-128 encrypted password
|
||||
}
|
||||
|
||||
// Errors
|
||||
|
||||
/// ## SerializerError
|
||||
///
|
||||
/// Contains the error for serializer/deserializer
|
||||
#[derive(std::fmt::Debug)]
|
||||
pub struct SerializerError {
|
||||
kind: SerializerErrorKind,
|
||||
msg: Option<String>,
|
||||
}
|
||||
|
||||
/// ## SerializerErrorKind
|
||||
///
|
||||
/// Describes the kind of error for the serializer/deserializer
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SerializerErrorKind {
|
||||
#[error("IO error")]
|
||||
IoError,
|
||||
#[error("Serialization error")]
|
||||
SerializationError,
|
||||
#[error("Syntax error")]
|
||||
SyntaxError,
|
||||
}
|
||||
|
||||
impl Default for UserHosts {
|
||||
fn default() -> Self {
|
||||
UserHosts {
|
||||
bookmarks: HashMap::new(),
|
||||
recents: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SerializerError {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new `SerializerError`
|
||||
pub fn new(kind: SerializerErrorKind) -> SerializerError {
|
||||
SerializerError { kind, msg: None }
|
||||
}
|
||||
|
||||
/// ### new_ex
|
||||
///
|
||||
/// Instantiates a new `SerializerError` with description message
|
||||
pub fn new_ex(kind: SerializerErrorKind, msg: String) -> SerializerError {
|
||||
let mut err: SerializerError = SerializerError::new(kind);
|
||||
err.msg = Some(msg);
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SerializerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match &self.msg {
|
||||
Some(msg) => write!(f, "{} ({})", self.kind, msg),
|
||||
None => write!(f, "{}", self.kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_bookmark_new() {
|
||||
let bookmark: Bookmark = Bookmark {
|
||||
address: String::from("192.168.1.1"),
|
||||
port: 22,
|
||||
protocol: String::from("SFTP"),
|
||||
username: String::from("root"),
|
||||
password: Some(String::from("password")),
|
||||
};
|
||||
let recent: Bookmark = Bookmark {
|
||||
address: String::from("192.168.1.2"),
|
||||
port: 22,
|
||||
protocol: String::from("SCP"),
|
||||
username: String::from("admin"),
|
||||
password: Some(String::from("password")),
|
||||
};
|
||||
let mut bookmarks: HashMap<String, Bookmark> = HashMap::with_capacity(1);
|
||||
bookmarks.insert(String::from("test"), bookmark);
|
||||
let mut recents: HashMap<String, Bookmark> = HashMap::with_capacity(1);
|
||||
recents.insert(String::from("ISO20201218T181432"), recent);
|
||||
let hosts: UserHosts = UserHosts {
|
||||
bookmarks: bookmarks,
|
||||
recents: recents,
|
||||
};
|
||||
// Verify
|
||||
let bookmark: &Bookmark = hosts.bookmarks.get(&String::from("test")).unwrap();
|
||||
assert_eq!(bookmark.address, String::from("192.168.1.1"));
|
||||
assert_eq!(bookmark.port, 22);
|
||||
assert_eq!(bookmark.protocol, String::from("SFTP"));
|
||||
assert_eq!(bookmark.username, String::from("root"));
|
||||
assert_eq!(
|
||||
*bookmark.password.as_ref().unwrap(),
|
||||
String::from("password")
|
||||
);
|
||||
let bookmark: &Bookmark = hosts
|
||||
.recents
|
||||
.get(&String::from("ISO20201218T181432"))
|
||||
.unwrap();
|
||||
assert_eq!(bookmark.address, String::from("192.168.1.2"));
|
||||
assert_eq!(bookmark.port, 22);
|
||||
assert_eq!(bookmark.protocol, String::from("SCP"));
|
||||
assert_eq!(bookmark.username, String::from("admin"));
|
||||
assert_eq!(
|
||||
*bookmark.password.as_ref().unwrap(),
|
||||
String::from("password")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_bookmark_errors() {
|
||||
let error: SerializerError = SerializerError::new(SerializerErrorKind::SyntaxError);
|
||||
assert!(error.msg.is_none());
|
||||
assert_eq!(format!("{}", error), String::from("Syntax error"));
|
||||
let error: SerializerError =
|
||||
SerializerError::new_ex(SerializerErrorKind::SyntaxError, String::from("bad syntax"));
|
||||
assert!(error.msg.is_some());
|
||||
assert_eq!(
|
||||
format!("{}", error),
|
||||
String::from("Syntax error (bad syntax)")
|
||||
);
|
||||
// Fmt
|
||||
assert_eq!(
|
||||
format!("{}", SerializerError::new(SerializerErrorKind::IoError)),
|
||||
String::from("IO error")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
SerializerError::new(SerializerErrorKind::SerializationError)
|
||||
),
|
||||
String::from("Serialization error")
|
||||
);
|
||||
}
|
||||
}
|
||||
222
src/bookmarks/serializer.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! ## Serializer
|
||||
//!
|
||||
//! `serializer` is the module which provides the serializer/deserializer for bookmarks
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
use super::{SerializerError, SerializerErrorKind, UserHosts};
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
pub struct BookmarkSerializer;
|
||||
|
||||
impl BookmarkSerializer {
|
||||
/// ### serialize
|
||||
///
|
||||
/// Serialize `UserHosts` into TOML and write content to writable
|
||||
pub fn serialize(
|
||||
&self,
|
||||
mut writable: Box<dyn Write>,
|
||||
hosts: &UserHosts,
|
||||
) -> Result<(), SerializerError> {
|
||||
// Serialize content
|
||||
let data: String = match toml::ser::to_string(hosts) {
|
||||
Ok(dt) => dt,
|
||||
Err(err) => {
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::SerializationError,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
// Write file
|
||||
match writable.write_all(data.as_bytes()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### deserialize
|
||||
///
|
||||
/// Read data from readable and deserialize its content as TOML
|
||||
pub fn deserialize(&self, mut readable: Box<dyn Read>) -> Result<UserHosts, SerializerError> {
|
||||
// Read file content
|
||||
let mut data: String = String::new();
|
||||
if let Err(err) = readable.read_to_string(&mut data) {
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
// Deserialize
|
||||
match toml::de::from_str(data.as_str()) {
|
||||
Ok(hosts) => Ok(hosts),
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::SyntaxError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::super::Bookmark;
|
||||
use super::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_serializer_deserialize_ok() {
|
||||
let toml_file: tempfile::NamedTempFile = create_good_toml();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
|
||||
// Parse
|
||||
let deserializer: BookmarkSerializer = BookmarkSerializer {};
|
||||
let hosts = deserializer.deserialize(Box::new(toml_file));
|
||||
assert!(hosts.is_ok());
|
||||
let hosts: UserHosts = hosts.ok().unwrap();
|
||||
// Verify hosts
|
||||
// Verify recents
|
||||
assert_eq!(hosts.recents.len(), 1);
|
||||
let host: &Bookmark = hosts.recents.get("ISO20201215T094000Z").unwrap();
|
||||
assert_eq!(host.address, String::from("172.16.104.10"));
|
||||
assert_eq!(host.port, 22);
|
||||
assert_eq!(host.protocol, String::from("SCP"));
|
||||
assert_eq!(host.username, String::from("root"));
|
||||
assert_eq!(host.password, None);
|
||||
// Verify bookmarks
|
||||
assert_eq!(hosts.bookmarks.len(), 3);
|
||||
let host: &Bookmark = hosts.bookmarks.get("raspberrypi2").unwrap();
|
||||
assert_eq!(host.address, String::from("192.168.1.31"));
|
||||
assert_eq!(host.port, 22);
|
||||
assert_eq!(host.protocol, String::from("SFTP"));
|
||||
assert_eq!(host.username, String::from("root"));
|
||||
assert_eq!(*host.password.as_ref().unwrap(), String::from("mypassword"));
|
||||
let host: &Bookmark = hosts.bookmarks.get("msi-estrem").unwrap();
|
||||
assert_eq!(host.address, String::from("192.168.1.30"));
|
||||
assert_eq!(host.port, 22);
|
||||
assert_eq!(host.protocol, String::from("SFTP"));
|
||||
assert_eq!(host.username, String::from("cvisintin"));
|
||||
assert_eq!(*host.password.as_ref().unwrap(), String::from("mysecret"));
|
||||
let host: &Bookmark = hosts.bookmarks.get("aws-server-prod1").unwrap();
|
||||
assert_eq!(host.address, String::from("51.23.67.12"));
|
||||
assert_eq!(host.port, 21);
|
||||
assert_eq!(host.protocol, String::from("FTPS"));
|
||||
assert_eq!(host.username, String::from("aws001"));
|
||||
assert_eq!(host.password, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_serializer_deserialize_nok() {
|
||||
let toml_file: tempfile::NamedTempFile = create_bad_toml();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
|
||||
// Parse
|
||||
let deserializer: BookmarkSerializer = BookmarkSerializer {};
|
||||
assert!(deserializer.deserialize(Box::new(toml_file)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_serializer_serialize() {
|
||||
let mut bookmarks: HashMap<String, Bookmark> = HashMap::with_capacity(2);
|
||||
// Push two samples
|
||||
bookmarks.insert(
|
||||
String::from("raspberrypi2"),
|
||||
Bookmark {
|
||||
address: String::from("192.168.1.31"),
|
||||
port: 22,
|
||||
protocol: String::from("SFTP"),
|
||||
username: String::from("root"),
|
||||
password: None,
|
||||
},
|
||||
);
|
||||
bookmarks.insert(
|
||||
String::from("msi-estrem"),
|
||||
Bookmark {
|
||||
address: String::from("192.168.1.30"),
|
||||
port: 4022,
|
||||
protocol: String::from("SFTP"),
|
||||
username: String::from("cvisintin"),
|
||||
password: Some(String::from("password")),
|
||||
},
|
||||
);
|
||||
let mut recents: HashMap<String, Bookmark> = HashMap::with_capacity(1);
|
||||
recents.insert(
|
||||
String::from("ISO20201215T094000Z"),
|
||||
Bookmark {
|
||||
address: String::from("192.168.1.254"),
|
||||
port: 3022,
|
||||
protocol: String::from("SCP"),
|
||||
username: String::from("omar"),
|
||||
password: Some(String::from("aaa")),
|
||||
},
|
||||
);
|
||||
let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
// Serialize
|
||||
let deserializer: BookmarkSerializer = BookmarkSerializer {};
|
||||
let hosts: UserHosts = UserHosts { bookmarks, recents };
|
||||
assert!(deserializer.serialize(Box::new(tmpfile), &hosts).is_ok());
|
||||
}
|
||||
|
||||
fn create_good_toml() -> tempfile::NamedTempFile {
|
||||
// Write
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
[bookmarks]
|
||||
raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root", password = "mypassword" }
|
||||
msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP", username = "cvisintin", password = "mysecret" }
|
||||
aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" }
|
||||
|
||||
[recents]
|
||||
ISO20201215T094000Z = { address = "172.16.104.10", port = 22, protocol = "SCP", username = "root" }
|
||||
"#;
|
||||
tmpfile.write_all(file_content.as_bytes()).unwrap();
|
||||
//write!(tmpfile, "[bookmarks]\nraspberrypi2 = {{ address = \"192.168.1.31\", port = 22, protocol = \"SFTP\", username = \"root\" }}\nmsi-estrem = {{ address = \"192.168.1.30\", port = 22, protocol = \"SFTP\", username = \"cvisintin\" }}\naws-server-prod1 = {{ address = \"51.23.67.12\", port = 21, protocol = \"FTPS\", username = \"aws001\" }}\n\n[recents]\nISO20201215T094000Z = {{ address = \"172.16.104.10\", port = 22, protocol = \"SCP\", username = \"root\" }}\n");
|
||||
tmpfile
|
||||
}
|
||||
|
||||
fn create_bad_toml() -> tempfile::NamedTempFile {
|
||||
// Write
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
[bookmarks]
|
||||
raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root"}
|
||||
msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP" }
|
||||
aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" }
|
||||
|
||||
[recents]
|
||||
ISO20201215T094000Z = { address = "172.16.104.10", protocol = "SCP", username = "root", port = 22 }
|
||||
"#;
|
||||
tmpfile.write_all(file_content.as_bytes()).unwrap();
|
||||
tmpfile
|
||||
}
|
||||
}
|
||||
248
src/config/mod.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! ## Config
|
||||
//!
|
||||
//! `config` is the module which provides access to termscp configuration
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Modules
|
||||
pub mod serializer;
|
||||
|
||||
// Deps
|
||||
extern crate edit;
|
||||
|
||||
// Locals
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
|
||||
// Ext
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Deserialize, Serialize, std::fmt::Debug)]
|
||||
/// ## UserConfig
|
||||
///
|
||||
/// UserConfig contains all the configurations for the user,
|
||||
/// supported by termscp
|
||||
pub struct UserConfig {
|
||||
pub user_interface: UserInterfaceConfig,
|
||||
pub remote: RemoteConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, std::fmt::Debug)]
|
||||
/// ## UserInterfaceConfig
|
||||
///
|
||||
/// UserInterfaceConfig provides all the keys to configure the user interface
|
||||
pub struct UserInterfaceConfig {
|
||||
pub text_editor: PathBuf,
|
||||
pub default_protocol: String,
|
||||
pub show_hidden_files: bool,
|
||||
pub check_for_updates: Option<bool>, // @! Since 0.3.3
|
||||
pub group_dirs: Option<String>,
|
||||
pub file_fmt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, std::fmt::Debug)]
|
||||
/// ## RemoteConfig
|
||||
///
|
||||
/// Contains configuratio related to remote hosts
|
||||
pub struct RemoteConfig {
|
||||
pub ssh_keys: HashMap<String, PathBuf>, // Association between host name and path to private key
|
||||
}
|
||||
|
||||
impl Default for UserConfig {
|
||||
fn default() -> Self {
|
||||
UserConfig {
|
||||
user_interface: UserInterfaceConfig::default(),
|
||||
remote: RemoteConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UserInterfaceConfig {
|
||||
fn default() -> Self {
|
||||
UserInterfaceConfig {
|
||||
text_editor: match edit::get_editor() {
|
||||
Ok(p) => p,
|
||||
Err(_) => PathBuf::from("nano"), // Default to nano
|
||||
},
|
||||
default_protocol: FileTransferProtocol::Sftp.to_string(),
|
||||
show_hidden_files: false,
|
||||
check_for_updates: Some(true),
|
||||
group_dirs: None,
|
||||
file_fmt: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RemoteConfig {
|
||||
fn default() -> Self {
|
||||
RemoteConfig {
|
||||
ssh_keys: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Errors
|
||||
|
||||
/// ## SerializerError
|
||||
///
|
||||
/// Contains the error for serializer/deserializer
|
||||
#[derive(std::fmt::Debug)]
|
||||
pub struct SerializerError {
|
||||
kind: SerializerErrorKind,
|
||||
msg: Option<String>,
|
||||
}
|
||||
|
||||
/// ## SerializerErrorKind
|
||||
///
|
||||
/// Describes the kind of error for the serializer/deserializer
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SerializerErrorKind {
|
||||
#[error("IO error")]
|
||||
IoError,
|
||||
#[error("Serialization error")]
|
||||
SerializationError,
|
||||
#[error("Syntax error")]
|
||||
SyntaxError,
|
||||
}
|
||||
|
||||
impl SerializerError {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new `SerializerError`
|
||||
pub fn new(kind: SerializerErrorKind) -> SerializerError {
|
||||
SerializerError { kind, msg: None }
|
||||
}
|
||||
|
||||
/// ### new_ex
|
||||
///
|
||||
/// Instantiates a new `SerializerError` with description message
|
||||
pub fn new_ex(kind: SerializerErrorKind, msg: String) -> SerializerError {
|
||||
let mut err: SerializerError = SerializerError::new(kind);
|
||||
err.msg = Some(msg);
|
||||
err
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SerializerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match &self.msg {
|
||||
Some(msg) => write!(f, "{} ({})", self.kind, msg),
|
||||
None => write!(f, "{}", self.kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn test_config_mod_new() {
|
||||
let mut keys: HashMap<String, PathBuf> = HashMap::with_capacity(1);
|
||||
keys.insert(
|
||||
String::from("192.168.1.31"),
|
||||
PathBuf::from("/tmp/private.key"),
|
||||
);
|
||||
let remote: RemoteConfig = RemoteConfig { ssh_keys: keys };
|
||||
let ui: UserInterfaceConfig = UserInterfaceConfig {
|
||||
default_protocol: String::from("SFTP"),
|
||||
text_editor: PathBuf::from("nano"),
|
||||
show_hidden_files: true,
|
||||
check_for_updates: Some(true),
|
||||
group_dirs: Some(String::from("first")),
|
||||
file_fmt: Some(String::from("{NAME}")),
|
||||
};
|
||||
let cfg: UserConfig = UserConfig {
|
||||
user_interface: ui,
|
||||
remote: remote,
|
||||
};
|
||||
assert_eq!(
|
||||
*cfg.remote
|
||||
.ssh_keys
|
||||
.get(&String::from("192.168.1.31"))
|
||||
.unwrap(),
|
||||
PathBuf::from("/tmp/private.key")
|
||||
);
|
||||
assert_eq!(cfg.user_interface.default_protocol, String::from("SFTP"));
|
||||
assert_eq!(cfg.user_interface.text_editor, PathBuf::from("nano"));
|
||||
assert_eq!(cfg.user_interface.show_hidden_files, true);
|
||||
assert_eq!(cfg.user_interface.check_for_updates, Some(true));
|
||||
assert_eq!(cfg.user_interface.group_dirs, Some(String::from("first")));
|
||||
assert_eq!(cfg.user_interface.file_fmt, Some(String::from("{NAME}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_mod_new_default() {
|
||||
// Force vim editor
|
||||
env::set_var(String::from("EDITOR"), String::from("vim"));
|
||||
// Get default
|
||||
let cfg: UserConfig = UserConfig::default();
|
||||
assert_eq!(cfg.user_interface.default_protocol, String::from("SFTP"));
|
||||
// Text editor
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
PathBuf::from(cfg.user_interface.text_editor.file_name().unwrap()), // NOTE: since edit 0.1.3 real path is used
|
||||
PathBuf::from("vim.EXE")
|
||||
);
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
PathBuf::from(cfg.user_interface.text_editor.file_name().unwrap()), // NOTE: since edit 0.1.3 real path is used
|
||||
PathBuf::from("vim")
|
||||
);
|
||||
assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true);
|
||||
assert_eq!(cfg.remote.ssh_keys.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_mod_errors() {
|
||||
let error: SerializerError = SerializerError::new(SerializerErrorKind::SyntaxError);
|
||||
assert!(error.msg.is_none());
|
||||
assert_eq!(format!("{}", error), String::from("Syntax error"));
|
||||
let error: SerializerError =
|
||||
SerializerError::new_ex(SerializerErrorKind::SyntaxError, String::from("bad syntax"));
|
||||
assert!(error.msg.is_some());
|
||||
assert_eq!(
|
||||
format!("{}", error),
|
||||
String::from("Syntax error (bad syntax)")
|
||||
);
|
||||
// Fmt
|
||||
assert_eq!(
|
||||
format!("{}", SerializerError::new(SerializerErrorKind::IoError)),
|
||||
String::from("IO error")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
SerializerError::new(SerializerErrorKind::SerializationError)
|
||||
),
|
||||
String::from("Serialization error")
|
||||
);
|
||||
}
|
||||
}
|
||||
250
src/config/serializer.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
//! ## Serializer
|
||||
//!
|
||||
//! `serializer` is the module which provides the serializer/deserializer for configuration
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
use super::{SerializerError, SerializerErrorKind, UserConfig};
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
pub struct ConfigSerializer;
|
||||
|
||||
impl ConfigSerializer {
|
||||
/// ### serialize
|
||||
///
|
||||
/// Serialize `UserConfig` into TOML and write content to writable
|
||||
pub fn serialize(
|
||||
&self,
|
||||
mut writable: Box<dyn Write>,
|
||||
cfg: &UserConfig,
|
||||
) -> Result<(), SerializerError> {
|
||||
// Serialize content
|
||||
let data: String = match toml::ser::to_string(cfg) {
|
||||
Ok(dt) => dt,
|
||||
Err(err) => {
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::SerializationError,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
// Write file
|
||||
match writable.write_all(data.as_bytes()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### deserialize
|
||||
///
|
||||
/// Read data from readable and deserialize its content as TOML
|
||||
pub fn deserialize(&self, mut readable: Box<dyn Read>) -> Result<UserConfig, SerializerError> {
|
||||
// Read file content
|
||||
let mut data: String = String::new();
|
||||
if let Err(err) = readable.read_to_string(&mut data) {
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
// Deserialize
|
||||
match toml::de::from_str(data.as_str()) {
|
||||
Ok(hosts) => Ok(hosts),
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::SyntaxError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use std::io::{Seek, SeekFrom};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_config_serializer_deserialize_ok() {
|
||||
let toml_file: tempfile::NamedTempFile = create_good_toml();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
|
||||
// Parse
|
||||
let deserializer: ConfigSerializer = ConfigSerializer {};
|
||||
let cfg = deserializer.deserialize(Box::new(toml_file));
|
||||
assert!(cfg.is_ok());
|
||||
let cfg: UserConfig = cfg.ok().unwrap();
|
||||
// Verify configuration
|
||||
// Verify ui
|
||||
assert_eq!(cfg.user_interface.default_protocol, String::from("SCP"));
|
||||
assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim"));
|
||||
assert_eq!(cfg.user_interface.show_hidden_files, true);
|
||||
assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true);
|
||||
assert_eq!(cfg.user_interface.group_dirs, Some(String::from("last")));
|
||||
assert_eq!(
|
||||
cfg.user_interface.file_fmt,
|
||||
Some(String::from("{NAME} {PEX}"))
|
||||
);
|
||||
// Verify keys
|
||||
assert_eq!(
|
||||
*cfg.remote
|
||||
.ssh_keys
|
||||
.get(&String::from("192.168.1.31"))
|
||||
.unwrap(),
|
||||
PathBuf::from("/home/omar/.ssh/raspberry.key")
|
||||
);
|
||||
assert_eq!(
|
||||
*cfg.remote
|
||||
.ssh_keys
|
||||
.get(&String::from("192.168.1.32"))
|
||||
.unwrap(),
|
||||
PathBuf::from("/home/omar/.ssh/beaglebone.key")
|
||||
);
|
||||
assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serializer_deserialize_ok_no_opts() {
|
||||
let toml_file: tempfile::NamedTempFile = create_good_toml_no_opts();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
|
||||
// Parse
|
||||
let deserializer: ConfigSerializer = ConfigSerializer {};
|
||||
let cfg = deserializer.deserialize(Box::new(toml_file));
|
||||
assert!(cfg.is_ok());
|
||||
let cfg: UserConfig = cfg.ok().unwrap();
|
||||
// Verify configuration
|
||||
// Verify ui
|
||||
assert_eq!(cfg.user_interface.default_protocol, String::from("SCP"));
|
||||
assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim"));
|
||||
assert_eq!(cfg.user_interface.show_hidden_files, true);
|
||||
assert_eq!(cfg.user_interface.group_dirs, None);
|
||||
assert!(cfg.user_interface.check_for_updates.is_none());
|
||||
assert_eq!(cfg.user_interface.file_fmt, None);
|
||||
// Verify keys
|
||||
assert_eq!(
|
||||
*cfg.remote
|
||||
.ssh_keys
|
||||
.get(&String::from("192.168.1.31"))
|
||||
.unwrap(),
|
||||
PathBuf::from("/home/omar/.ssh/raspberry.key")
|
||||
);
|
||||
assert_eq!(
|
||||
*cfg.remote
|
||||
.ssh_keys
|
||||
.get(&String::from("192.168.1.32"))
|
||||
.unwrap(),
|
||||
PathBuf::from("/home/omar/.ssh/beaglebone.key")
|
||||
);
|
||||
assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serializer_deserialize_nok() {
|
||||
let toml_file: tempfile::NamedTempFile = create_bad_toml();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
|
||||
// Parse
|
||||
let deserializer: ConfigSerializer = ConfigSerializer {};
|
||||
assert!(deserializer.deserialize(Box::new(toml_file)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serializer_serialize() {
|
||||
let mut cfg: UserConfig = UserConfig::default();
|
||||
let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap();
|
||||
// Insert key
|
||||
cfg.remote.ssh_keys.insert(
|
||||
String::from("192.168.1.31"),
|
||||
PathBuf::from("/home/omar/.ssh/id_rsa"),
|
||||
);
|
||||
// Serialize
|
||||
let serializer: ConfigSerializer = ConfigSerializer {};
|
||||
let writer: Box<dyn Write> = Box::new(std::fs::File::create(toml_file.path()).unwrap());
|
||||
assert!(serializer.serialize(writer, &cfg).is_ok());
|
||||
// Reload configuration and check if it's ok
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
|
||||
assert!(serializer.deserialize(Box::new(toml_file)).is_ok());
|
||||
}
|
||||
|
||||
fn create_good_toml() -> tempfile::NamedTempFile {
|
||||
// Write
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
[user_interface]
|
||||
default_protocol = "SCP"
|
||||
text_editor = "vim"
|
||||
show_hidden_files = true
|
||||
check_for_updates = true
|
||||
group_dirs = "last"
|
||||
file_fmt = "{NAME} {PEX}"
|
||||
|
||||
[remote.ssh_keys]
|
||||
"192.168.1.31" = "/home/omar/.ssh/raspberry.key"
|
||||
"192.168.1.32" = "/home/omar/.ssh/beaglebone.key"
|
||||
"#;
|
||||
tmpfile.write_all(file_content.as_bytes()).unwrap();
|
||||
tmpfile
|
||||
}
|
||||
|
||||
fn create_good_toml_no_opts() -> tempfile::NamedTempFile {
|
||||
// Write
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
[user_interface]
|
||||
default_protocol = "SCP"
|
||||
text_editor = "vim"
|
||||
show_hidden_files = true
|
||||
|
||||
[remote.ssh_keys]
|
||||
"192.168.1.31" = "/home/omar/.ssh/raspberry.key"
|
||||
"192.168.1.32" = "/home/omar/.ssh/beaglebone.key"
|
||||
"#;
|
||||
tmpfile.write_all(file_content.as_bytes()).unwrap();
|
||||
tmpfile
|
||||
}
|
||||
|
||||
fn create_bad_toml() -> tempfile::NamedTempFile {
|
||||
// Write
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
[user_interface]
|
||||
default_protocol = "SFTP"
|
||||
|
||||
[remote.ssh_keys]
|
||||
"192.168.1.31" = "/home/omar/.ssh/raspberry.key"
|
||||
"#;
|
||||
tmpfile.write_all(file_content.as_bytes()).unwrap();
|
||||
tmpfile
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,39 @@
|
||||
//!
|
||||
//! `filetransfer` is the module which provides the trait file transfers must implement and the different file transfers
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// dependencies
|
||||
extern crate wildmatch;
|
||||
// locals
|
||||
use crate::fs::{FsEntry, FsFile};
|
||||
// ext
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::fs::{FsEntry, FsFile};
|
||||
|
||||
// Transfers
|
||||
use thiserror::Error;
|
||||
use wildmatch::WildMatch;
|
||||
// exports
|
||||
pub mod ftp_transfer;
|
||||
pub mod scp_transfer;
|
||||
pub mod sftp_transfer;
|
||||
@@ -37,7 +43,7 @@ pub mod sftp_transfer;
|
||||
///
|
||||
/// This enum defines the different transfer protocol available in TermSCP
|
||||
|
||||
#[derive(std::cmp::PartialEq, std::fmt::Debug, std::clone::Clone)]
|
||||
#[derive(PartialEq, std::fmt::Debug, std::clone::Clone, Copy)]
|
||||
pub enum FileTransferProtocol {
|
||||
Sftp,
|
||||
Scp,
|
||||
@@ -57,19 +63,31 @@ pub struct FileTransferError {
|
||||
///
|
||||
/// FileTransferErrorType defines the possible errors available for a file transfer
|
||||
#[allow(dead_code)]
|
||||
#[derive(std::fmt::Debug)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum FileTransferErrorType {
|
||||
#[error("Authentication failed")]
|
||||
AuthenticationFailed,
|
||||
#[error("Bad address syntax")]
|
||||
BadAddress,
|
||||
#[error("Connection error")]
|
||||
ConnectionError,
|
||||
#[error("SSL error")]
|
||||
SslError,
|
||||
#[error("Could not stat directory")]
|
||||
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")]
|
||||
PexError,
|
||||
#[error("Protocol error")]
|
||||
ProtocolError,
|
||||
#[error("Uninitialized session")]
|
||||
UninitializedSession,
|
||||
#[error("Unsupported feature")]
|
||||
UnsupportedFeature,
|
||||
}
|
||||
|
||||
@@ -78,10 +96,7 @@ impl FileTransferError {
|
||||
///
|
||||
/// Instantiates a new FileTransferError
|
||||
pub fn new(code: FileTransferErrorType) -> FileTransferError {
|
||||
FileTransferError {
|
||||
code: code,
|
||||
msg: None,
|
||||
}
|
||||
FileTransferError { code, msg: None }
|
||||
}
|
||||
|
||||
/// ### new_ex
|
||||
@@ -96,25 +111,9 @@ impl FileTransferError {
|
||||
|
||||
impl std::fmt::Display for FileTransferError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
let err: String = match &self.code {
|
||||
FileTransferErrorType::AuthenticationFailed => String::from("Authentication failed"),
|
||||
FileTransferErrorType::BadAddress => String::from("Bad address syntax"),
|
||||
FileTransferErrorType::ConnectionError => String::from("Connection error"),
|
||||
FileTransferErrorType::DirStatFailed => String::from("Could not stat directory"),
|
||||
FileTransferErrorType::FileCreateDenied => String::from("Failed to create file"),
|
||||
FileTransferErrorType::IoErr(err) => format!("IO Error: {}", err),
|
||||
FileTransferErrorType::NoSuchFileOrDirectory => {
|
||||
String::from("No such file or directory")
|
||||
}
|
||||
FileTransferErrorType::PexError => String::from("Not enough permissions"),
|
||||
FileTransferErrorType::ProtocolError => String::from("Protocol error"),
|
||||
FileTransferErrorType::SslError => String::from("SSL error"),
|
||||
FileTransferErrorType::UninitializedSession => String::from("Uninitialized session"),
|
||||
FileTransferErrorType::UnsupportedFeature => String::from("Unsupported feature"),
|
||||
};
|
||||
match &self.msg {
|
||||
Some(msg) => write!(f, "{} ({})", err, msg),
|
||||
None => write!(f, "{}", err),
|
||||
Some(msg) => write!(f, "{} ({})", self.code, msg),
|
||||
None => write!(f, "{}", self.code),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +159,11 @@ pub trait FileTransfer {
|
||||
|
||||
fn change_dir(&mut self, dir: &Path) -> Result<PathBuf, FileTransferError>;
|
||||
|
||||
/// ### copy
|
||||
///
|
||||
/// Copy file to destination
|
||||
fn copy(&mut self, src: &FsEntry, dst: &Path) -> Result<(), FileTransferError>;
|
||||
|
||||
/// ### list_dir
|
||||
///
|
||||
/// List directory entries
|
||||
@@ -187,13 +191,22 @@ pub trait FileTransfer {
|
||||
/// Stat file and return FsEntry
|
||||
fn stat(&mut self, path: &Path) -> Result<FsEntry, FileTransferError>;
|
||||
|
||||
/// ### exec
|
||||
///
|
||||
/// Execute a command on remote host
|
||||
fn exec(&mut self, cmd: &str) -> Result<String, FileTransferError>;
|
||||
|
||||
/// ### send_file
|
||||
///
|
||||
/// Send file to remote
|
||||
/// File name is referred to the name of the file as it will be saved
|
||||
/// Data contains the file data
|
||||
/// Returns file and its size
|
||||
fn send_file(&mut self, local: &FsFile, file_name: &Path) -> Result<Box<dyn Write>, FileTransferError>;
|
||||
fn send_file(
|
||||
&mut self,
|
||||
local: &FsFile,
|
||||
file_name: &Path,
|
||||
) -> Result<Box<dyn Write>, FileTransferError>;
|
||||
|
||||
/// ### recv_file
|
||||
///
|
||||
@@ -218,4 +231,255 @@ pub trait FileTransfer {
|
||||
/// 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>;
|
||||
|
||||
/// ### find
|
||||
///
|
||||
/// Find files from current directory (in all subdirectories) whose name matches the provided search
|
||||
/// Search supports wildcards ('?', '*')
|
||||
fn find(&mut self, search: &str) -> Result<Vec<FsEntry>, FileTransferError> {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
// Starting from current directory, iter dir
|
||||
match self.pwd() {
|
||||
Ok(p) => self.iter_search(p.as_path(), &WildMatch::new(search)),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
false => Err(FileTransferError::new(
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### iter_search
|
||||
///
|
||||
/// Search recursively in `dir` for file matching the wildcard.
|
||||
/// NOTE: DON'T RE-IMPLEMENT THIS FUNCTION, unless the file transfer provides a faster way to do so
|
||||
/// NOTE: don't call this method from outside; consider it as private
|
||||
fn iter_search(
|
||||
&mut self,
|
||||
dir: &Path,
|
||||
filter: &WildMatch,
|
||||
) -> Result<Vec<FsEntry>, FileTransferError> {
|
||||
let mut drained: Vec<FsEntry> = Vec::new();
|
||||
// Scan directory
|
||||
match self.list_dir(dir) {
|
||||
Ok(entries) => {
|
||||
/* For each entry:
|
||||
- if is dir: call iter_search with `dir`
|
||||
- push `iter_search` result to `drained`
|
||||
- if is file: check if it matches `filter`
|
||||
- if it matches `filter`: push to to filter
|
||||
*/
|
||||
for entry in entries.iter() {
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
// If directory name, matches wildcard, push it to drained
|
||||
if filter.matches(dir.name.as_str()) {
|
||||
drained.push(FsEntry::Directory(dir.clone()));
|
||||
}
|
||||
match self.iter_search(dir.abs_path.as_path(), filter) {
|
||||
Ok(mut filtered) => drained.append(&mut filtered),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
if filter.matches(file.name.as_str()) {
|
||||
drained.push(FsEntry::File(file.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traits
|
||||
|
||||
impl std::string::ToString for FileTransferProtocol {
|
||||
fn to_string(&self) -> String {
|
||||
String::from(match self {
|
||||
FileTransferProtocol::Ftp(secure) => match secure {
|
||||
true => "FTPS",
|
||||
false => "FTP",
|
||||
},
|
||||
FileTransferProtocol::Scp => "SCP",
|
||||
FileTransferProtocol::Sftp => "SFTP",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for FileTransferProtocol {
|
||||
type Err = ();
|
||||
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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::string::ToString;
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_mod_protocol() {
|
||||
assert_eq!(
|
||||
FileTransferProtocol::Ftp(true),
|
||||
FileTransferProtocol::Ftp(true)
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::Ftp(false),
|
||||
FileTransferProtocol::Ftp(false)
|
||||
);
|
||||
// From str
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("FTPS").ok().unwrap(),
|
||||
FileTransferProtocol::Ftp(true)
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("ftps").ok().unwrap(),
|
||||
FileTransferProtocol::Ftp(true)
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("FTP").ok().unwrap(),
|
||||
FileTransferProtocol::Ftp(false)
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("ftp").ok().unwrap(),
|
||||
FileTransferProtocol::Ftp(false)
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("SFTP").ok().unwrap(),
|
||||
FileTransferProtocol::Sftp
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("sftp").ok().unwrap(),
|
||||
FileTransferProtocol::Sftp
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("SCP").ok().unwrap(),
|
||||
FileTransferProtocol::Scp
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("scp").ok().unwrap(),
|
||||
FileTransferProtocol::Scp
|
||||
);
|
||||
// Error
|
||||
assert!(FileTransferProtocol::from_str("dummy").is_err());
|
||||
// To String
|
||||
assert_eq!(
|
||||
FileTransferProtocol::Ftp(true).to_string(),
|
||||
String::from("FTPS")
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::Ftp(false).to_string(),
|
||||
String::from("FTP")
|
||||
);
|
||||
assert_eq!(FileTransferProtocol::Scp.to_string(), String::from("SCP"));
|
||||
assert_eq!(FileTransferProtocol::Sftp.to_string(), String::from("SFTP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_mod_error() {
|
||||
let err: FileTransferError = FileTransferError::new_ex(
|
||||
FileTransferErrorType::IoErr(std::io::Error::from(std::io::ErrorKind::AddrInUse)),
|
||||
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)")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::AuthenticationFailed)
|
||||
),
|
||||
String::from("Authentication failed")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::BadAddress)
|
||||
),
|
||||
String::from("Bad address syntax")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::ConnectionError)
|
||||
),
|
||||
String::from("Connection error")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::DirStatFailed)
|
||||
),
|
||||
String::from("Could not stat directory")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::FileCreateDenied)
|
||||
),
|
||||
String::from("Failed to create file")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::NoSuchFileOrDirectory)
|
||||
),
|
||||
String::from("No such file or directory")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::PexError)
|
||||
),
|
||||
String::from("Not enough permissions")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::ProtocolError)
|
||||
),
|
||||
String::from("Protocol error")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::SslError)
|
||||
),
|
||||
String::from("SSL error")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::UninitializedSession)
|
||||
),
|
||||
String::from("Uninitialized session")
|
||||
);
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{}",
|
||||
FileTransferError::new(FileTransferErrorType::UnsupportedFeature)
|
||||
),
|
||||
String::from("Unsupported feature")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,43 +2,49 @@
|
||||
//!
|
||||
//! `scps_transfer` is the module which provides the implementation for the SCP file transfer
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// Dependencies
|
||||
#[cfg(os_target = "windows")]
|
||||
extern crate path_slash;
|
||||
extern crate regex;
|
||||
extern crate ssh2;
|
||||
|
||||
// Locals
|
||||
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
|
||||
use crate::fs::{FsDirectory, FsEntry, FsFile};
|
||||
use crate::utils::lstime_to_systime;
|
||||
use crate::system::sshkey_storage::SshKeyStorage;
|
||||
use crate::utils::parser::parse_lstime;
|
||||
|
||||
// Includes
|
||||
use regex::Regex;
|
||||
use ssh2::{Channel, Session};
|
||||
use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// ## ScpFileTransfer
|
||||
///
|
||||
@@ -46,23 +52,39 @@ use std::time::SystemTime;
|
||||
pub struct ScpFileTransfer {
|
||||
session: Option<Session>,
|
||||
wrkdir: PathBuf,
|
||||
key_storage: SshKeyStorage,
|
||||
}
|
||||
|
||||
impl ScpFileTransfer {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new ScpFileTransfer
|
||||
pub fn new() -> ScpFileTransfer {
|
||||
pub fn new(key_storage: SshKeyStorage) -> ScpFileTransfer {
|
||||
ScpFileTransfer {
|
||||
session: None,
|
||||
wrkdir: PathBuf::from("~"),
|
||||
key_storage,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### resolve
|
||||
///
|
||||
/// Fix provided path; on Windows fixes the backslashes, converting them to slashes
|
||||
/// While on POSIX does nothing
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve(p: &Path) -> PathBuf {
|
||||
PathBuf::from(path_slash::PathExt::to_slash_lossy(p).as_str())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
fn resolve(p: &Path) -> PathBuf {
|
||||
p.to_path_buf()
|
||||
}
|
||||
|
||||
/// ### parse_ls_output
|
||||
///
|
||||
/// Parse a line of `ls -l` output and tokenize the output into a `FsEntry`
|
||||
fn parse_ls_output(&self, path: &Path, line: &str) -> Result<FsEntry, ()> {
|
||||
fn parse_ls_output(&mut self, path: &Path, line: &str) -> Result<FsEntry, ()> {
|
||||
// Prepare list regex
|
||||
// NOTE: about this damn regex <https://stackoverflow.com/questions/32480890/is-there-a-regex-to-parse-the-values-from-an-ftp-directory-listing>
|
||||
lazy_static! {
|
||||
@@ -79,7 +101,8 @@ impl ScpFileTransfer {
|
||||
}
|
||||
// Collect metadata
|
||||
// Get if is directory and if is symlink
|
||||
let (is_dir, is_symlink): (bool, bool) = match metadata.get(1).unwrap().as_str() {
|
||||
let (mut is_dir, is_symlink): (bool, bool) = match metadata.get(1).unwrap().as_str()
|
||||
{
|
||||
"-" => (false, false),
|
||||
"l" => (false, true),
|
||||
"d" => (true, false),
|
||||
@@ -89,66 +112,30 @@ impl ScpFileTransfer {
|
||||
if metadata.get(2).unwrap().as_str().len() < 9 {
|
||||
return Err(());
|
||||
}
|
||||
// Get unix pex
|
||||
let unix_pex: (u8, u8, u8) = {
|
||||
let owner_pex: u8 = {
|
||||
let mut count: u8 = 0;
|
||||
for (i, c) in metadata.get(2).unwrap().as_str()[0..3].chars().enumerate() {
|
||||
match c {
|
||||
'-' => {}
|
||||
_ => {
|
||||
count = count
|
||||
+ match i {
|
||||
0 => 4,
|
||||
1 => 2,
|
||||
2 => 1,
|
||||
_ => 0,
|
||||
}
|
||||
|
||||
let pex = |range: Range<usize>| {
|
||||
let mut count: u8 = 0;
|
||||
for (i, c) in metadata.get(2).unwrap().as_str()[range].chars().enumerate() {
|
||||
match c {
|
||||
'-' => {}
|
||||
_ => {
|
||||
count += match i {
|
||||
0 => 4,
|
||||
1 => 2,
|
||||
2 => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
};
|
||||
let group_pex: u8 = {
|
||||
let mut count: u8 = 0;
|
||||
for (i, c) in metadata.get(2).unwrap().as_str()[3..6].chars().enumerate() {
|
||||
match c {
|
||||
'-' => {}
|
||||
_ => {
|
||||
count = count
|
||||
+ match i {
|
||||
0 => 4,
|
||||
1 => 2,
|
||||
2 => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
};
|
||||
let others_pex: u8 = {
|
||||
let mut count: u8 = 0;
|
||||
for (i, c) in metadata.get(2).unwrap().as_str()[6..9].chars().enumerate() {
|
||||
match c {
|
||||
'-' => {}
|
||||
_ => {
|
||||
count = count
|
||||
+ match i {
|
||||
0 => 4,
|
||||
1 => 2,
|
||||
2 => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
count
|
||||
};
|
||||
(owner_pex, group_pex, others_pex)
|
||||
}
|
||||
count
|
||||
};
|
||||
|
||||
// Get unix pex
|
||||
let unix_pex = (pex(0..3), pex(3..6), pex(6..9));
|
||||
|
||||
// Parse mtime and convert to SystemTime
|
||||
let mtime: SystemTime = match lstime_to_systime(
|
||||
let mtime: SystemTime = match parse_lstime(
|
||||
metadata.get(7).unwrap().as_str(),
|
||||
"%b %d %Y",
|
||||
"%b %d %H:%M",
|
||||
@@ -167,10 +154,12 @@ impl ScpFileTransfer {
|
||||
Err(_) => None,
|
||||
};
|
||||
// Get filesize
|
||||
let filesize: usize = match metadata.get(6).unwrap().as_str().parse::<usize>() {
|
||||
Ok(sz) => sz,
|
||||
Err(_) => 0,
|
||||
};
|
||||
let filesize: usize = metadata
|
||||
.get(6)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.parse::<usize>()
|
||||
.unwrap_or(0);
|
||||
// Get link and name
|
||||
let (file_name, symlink_path): (String, Option<PathBuf>) = match is_symlink {
|
||||
true => self.get_name_and_link(metadata.get(8).unwrap().as_str()),
|
||||
@@ -178,39 +167,63 @@ impl ScpFileTransfer {
|
||||
};
|
||||
// Check if file_name is '.' or '..'
|
||||
if file_name.as_str() == "." || file_name.as_str() == ".." {
|
||||
return Err(())
|
||||
return Err(());
|
||||
}
|
||||
let mut abs_path: PathBuf = PathBuf::from(path);
|
||||
let extension: Option<String> = match abs_path.as_path().extension() {
|
||||
// Get symlink; PATH mustn't be equal to filename
|
||||
let symlink: Option<Box<FsEntry>> = match symlink_path {
|
||||
None => None,
|
||||
Some(s) => Some(String::from(s.to_string_lossy())),
|
||||
Some(p) => match p.file_name().unwrap_or(&std::ffi::OsStr::new(""))
|
||||
== file_name.as_str()
|
||||
{
|
||||
// If name is equal, don't stat path; otherwise it would get stuck
|
||||
true => None,
|
||||
false => match self.stat(p.as_path()) {
|
||||
// If path match filename
|
||||
Ok(e) => {
|
||||
// If e is a directory, set is_dir to true
|
||||
if e.is_dir() {
|
||||
is_dir = true;
|
||||
}
|
||||
Some(Box::new(e))
|
||||
}
|
||||
Err(_) => None, // Ignore errors
|
||||
},
|
||||
},
|
||||
};
|
||||
// Re-check if is directory
|
||||
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());
|
||||
// Get extension
|
||||
let extension: Option<String> = abs_path
|
||||
.as_path()
|
||||
.extension()
|
||||
.map(|s| String::from(s.to_string_lossy()));
|
||||
// Return
|
||||
// Push to entries
|
||||
Ok(match is_dir {
|
||||
true => FsEntry::Directory(FsDirectory {
|
||||
name: file_name,
|
||||
abs_path: abs_path,
|
||||
abs_path,
|
||||
last_change_time: mtime,
|
||||
last_access_time: mtime,
|
||||
creation_time: mtime,
|
||||
readonly: false,
|
||||
symlink: symlink_path,
|
||||
symlink,
|
||||
user: uid,
|
||||
group: gid,
|
||||
unix_pex: Some(unix_pex),
|
||||
}),
|
||||
false => FsEntry::File(FsFile {
|
||||
name: file_name,
|
||||
abs_path: abs_path,
|
||||
abs_path,
|
||||
last_change_time: mtime,
|
||||
last_access_time: mtime,
|
||||
creation_time: mtime,
|
||||
size: filesize,
|
||||
ftype: extension,
|
||||
readonly: false,
|
||||
symlink: symlink_path,
|
||||
symlink,
|
||||
user: uid,
|
||||
group: gid,
|
||||
unix_pex: Some(unix_pex),
|
||||
@@ -227,10 +240,7 @@ impl ScpFileTransfer {
|
||||
fn get_name_and_link(&self, token: &str) -> (String, Option<PathBuf>) {
|
||||
let tokens: Vec<&str> = token.split(" -> ").collect();
|
||||
let filename: String = String::from(*tokens.get(0).unwrap());
|
||||
let symlink: Option<PathBuf> = match tokens.get(1) {
|
||||
Some(s) => Some(PathBuf::from(s)),
|
||||
None => None,
|
||||
};
|
||||
let symlink: Option<PathBuf> = tokens.get(1).map(PathBuf::from);
|
||||
(filename, symlink)
|
||||
}
|
||||
|
||||
@@ -302,12 +312,34 @@ impl FileTransfer for ScpFileTransfer {
|
||||
password: Option<String>,
|
||||
) -> Result<Option<String>, FileTransferError> {
|
||||
// Setup tcp stream
|
||||
let tcp: TcpStream = match TcpStream::connect(format!("{}:{}", address, port)) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
let socket_addresses: Vec<SocketAddr> =
|
||||
match format!("{}:{}", address, port).to_socket_addrs() {
|
||||
Ok(s) => s.collect(),
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::BadAddress,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let mut tcp: Option<TcpStream> = None;
|
||||
// Try addresses
|
||||
for socket_addr in socket_addresses.iter() {
|
||||
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
|
||||
Ok(stream) => {
|
||||
tcp = Some(stream);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
// If stream is None, return connection timeout
|
||||
let tcp: TcpStream = match tcp {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::BadAddress,
|
||||
format!("{}", err),
|
||||
FileTransferErrorType::ConnectionError,
|
||||
String::from("Connection timeout"),
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -317,7 +349,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -327,31 +359,47 @@ impl FileTransfer for ScpFileTransfer {
|
||||
if let Err(err) = session.handshake() {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
let username: String = match username {
|
||||
Some(u) => u.clone(),
|
||||
Some(u) => u,
|
||||
None => String::from(""),
|
||||
};
|
||||
// Try authenticating with user agent
|
||||
if let Err(_) = session.userauth_agent(username.as_str()) {
|
||||
// Try authentication with password then
|
||||
if let Err(err) = session.userauth_password(
|
||||
username.as_str(),
|
||||
password.unwrap_or(String::from("")).as_str(),
|
||||
) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
format!("{}", err),
|
||||
));
|
||||
// Check if it is possible to authenticate using a RSA key
|
||||
match self
|
||||
.key_storage
|
||||
.resolve(address.as_str(), username.as_str())
|
||||
{
|
||||
Some(rsa_key) => {
|
||||
// Authenticate with RSA key
|
||||
if let Err(err) = session.userauth_pubkey_file(
|
||||
username.as_str(),
|
||||
None,
|
||||
rsa_key.as_path(),
|
||||
password.as_deref(),
|
||||
) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Proceeed with username/password authentication
|
||||
if let Err(err) = session.userauth_password(
|
||||
username.as_str(),
|
||||
password.unwrap_or_else(|| String::from("")).as_str(),
|
||||
) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get banner
|
||||
let banner: Option<String> = match session.banner() {
|
||||
Some(s) => Some(String::from(s)),
|
||||
None => None,
|
||||
};
|
||||
let banner: Option<String> = session.banner().map(String::from);
|
||||
// Set session
|
||||
self.session = Some(session);
|
||||
// Get working directory
|
||||
@@ -377,7 +425,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -391,10 +439,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
///
|
||||
/// Indicates whether the client is connected to remote
|
||||
fn is_connected(&self) -> bool {
|
||||
match self.session.as_ref() {
|
||||
Some(_) => true,
|
||||
None => false,
|
||||
}
|
||||
self.session.as_ref().is_some()
|
||||
}
|
||||
|
||||
/// ### pwd
|
||||
@@ -423,7 +468,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
false => {
|
||||
let mut p: PathBuf = PathBuf::from(".");
|
||||
p.push(dir);
|
||||
p
|
||||
Self::resolve(p.as_path())
|
||||
}
|
||||
};
|
||||
// Change directory
|
||||
@@ -435,7 +480,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
// Trim
|
||||
let output: String = String::from(output.as_str().trim());
|
||||
// Check if output starts with 0; should be 0{PWD}
|
||||
match output.as_str().starts_with("0") {
|
||||
match output.as_str().starts_with('0') {
|
||||
true => {
|
||||
// Set working directory
|
||||
self.wrkdir = PathBuf::from(&output.as_str()[1..].trim());
|
||||
@@ -450,7 +495,49 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
false => Err(FileTransferError::new(
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### copy
|
||||
///
|
||||
/// Copy file to destination
|
||||
fn copy(&mut self, src: &FsEntry, dst: &Path) -> Result<(), FileTransferError> {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let dst: PathBuf = Self::resolve(dst);
|
||||
// Run `cp -rf`
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -468,10 +555,11 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
// Send ls -l to path
|
||||
let path: PathBuf = Self::resolve(path);
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_path(),
|
||||
format!("unset LANG; ls -la \"{}\"", path.display()).as_str(),
|
||||
format!("unset LANG; ls -la \"{}/\"", path.display()).as_str(),
|
||||
) {
|
||||
Ok(output) => {
|
||||
// Split output by (\r)\n
|
||||
@@ -480,7 +568,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
for line in lines.iter() {
|
||||
// First line must always be ignored
|
||||
// Parse row, if ok push to entries
|
||||
if let Ok(entry) = self.parse_ls_output(path, line) {
|
||||
if let Ok(entry) = self.parse_ls_output(path.as_path(), line) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
@@ -488,7 +576,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -505,6 +593,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
fn mkdir(&mut self, dir: &Path) -> Result<(), FileTransferError> {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let dir: PathBuf = Self::resolve(dir);
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
// Mkdir dir && echo 0
|
||||
match self.perform_shell_cmd_with_path(
|
||||
@@ -524,7 +613,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -542,10 +631,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
// Get path
|
||||
let path: PathBuf = match file {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
};
|
||||
let path: PathBuf = file.get_abs_path();
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_path(),
|
||||
@@ -564,7 +650,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -581,10 +667,8 @@ impl FileTransfer for ScpFileTransfer {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
// Get path
|
||||
let path: PathBuf = match file {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
};
|
||||
let dst: PathBuf = Self::resolve(dst);
|
||||
let path: PathBuf = file.get_abs_path();
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_path(),
|
||||
@@ -608,7 +692,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -622,27 +706,23 @@ impl FileTransfer for ScpFileTransfer {
|
||||
///
|
||||
/// Stat file and return FsEntry
|
||||
fn stat(&mut self, path: &Path) -> Result<FsEntry, FileTransferError> {
|
||||
if path.is_dir() {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::UnsupportedFeature,
|
||||
String::from("stat is not supported for directories"),
|
||||
));
|
||||
}
|
||||
let path: PathBuf = match path.is_absolute() {
|
||||
true => PathBuf::from(path),
|
||||
false => {
|
||||
let mut p: PathBuf = self.wrkdir.clone();
|
||||
p.push(path);
|
||||
p
|
||||
Self::resolve(p.as_path())
|
||||
}
|
||||
};
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(
|
||||
p.as_path(),
|
||||
format!("ls -l \"{}\"", path.display()).as_str(),
|
||||
) {
|
||||
// make command; Directories require `-d` option
|
||||
let cmd: String = match path.to_string_lossy().ends_with('/') {
|
||||
true => format!("ls -ld \"{}\"", path.display()),
|
||||
false => format!("ls -l \"{}\"", path.display()),
|
||||
};
|
||||
match self.perform_shell_cmd_with_path(p.as_path(), cmd.as_str()) {
|
||||
Ok(line) => {
|
||||
// Parse ls line
|
||||
let parent: PathBuf = match path.as_path().parent() {
|
||||
@@ -663,7 +743,28 @@ impl FileTransfer for ScpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
false => Err(FileTransferError::new(
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### exec
|
||||
///
|
||||
/// Execute a command on remote host
|
||||
fn exec(&mut self, cmd: &str) -> Result<String, FileTransferError> {
|
||||
match self.is_connected() {
|
||||
true => {
|
||||
let p: PathBuf = self.wrkdir.clone();
|
||||
match self.perform_shell_cmd_with_path(p.as_path(), cmd) {
|
||||
Ok(output) => Ok(output),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -686,6 +787,7 @@ impl FileTransfer for ScpFileTransfer {
|
||||
) -> Result<Box<dyn Write>, FileTransferError> {
|
||||
match self.session.as_ref() {
|
||||
Some(session) => {
|
||||
let file_name: PathBuf = Self::resolve(file_name);
|
||||
// Set blocking to true
|
||||
session.set_blocking(true);
|
||||
// Calculate file mode
|
||||
@@ -711,11 +813,17 @@ impl FileTransfer for ScpFileTransfer {
|
||||
};
|
||||
(mtime, atime)
|
||||
};
|
||||
match session.scp_send(file_name, mode, local.size as u64, Some(times)) {
|
||||
Ok(channel) => Ok(Box::new(BufWriter::with_capacity(8192, channel))),
|
||||
// We need to get the size of local; NOTE: don't use the `size` attribute, since might be out of sync
|
||||
let file_size: u64 = match std::fs::metadata(local.abs_path.as_path()) {
|
||||
Ok(metadata) => metadata.len(),
|
||||
Err(_) => local.size as u64, // NOTE: fallback to fsentry 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))),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -735,10 +843,10 @@ impl FileTransfer for ScpFileTransfer {
|
||||
// Set blocking to true
|
||||
session.set_blocking(true);
|
||||
match session.scp_recv(file.abs_path.as_path()) {
|
||||
Ok(reader) => Ok(Box::new(BufReader::with_capacity(8192, reader.0))),
|
||||
Ok(reader) => Ok(Box::new(BufReader::with_capacity(65536, reader.0))),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -780,14 +888,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_new() {
|
||||
let client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client.session.is_none());
|
||||
assert_eq!(client.is_connected(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_connect() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert_eq!(client.is_connected(), false);
|
||||
assert!(client
|
||||
.connect(
|
||||
@@ -806,7 +914,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_filetransfer_scp_bad_auth() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -819,7 +927,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_no_credentials() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(String::from("test.rebex.net"), 22, None, None)
|
||||
.is_err());
|
||||
@@ -827,7 +935,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_bad_server() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("mybadserver.veryverybad.awful"),
|
||||
@@ -839,7 +947,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_filetransfer_scp_pwd() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -857,9 +965,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
fn test_filetransfer_scp_cwd() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -880,7 +988,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_cwd_error() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -903,7 +1011,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_ls() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -924,7 +1032,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_stat() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -946,9 +1054,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_exec() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
22,
|
||||
Some(String::from("demo")),
|
||||
Some(String::from("password"))
|
||||
)
|
||||
.is_ok());
|
||||
// Check session and scp
|
||||
assert!(client.session.is_some());
|
||||
// Exec
|
||||
assert_eq!(client.exec("echo 5").ok().unwrap().as_str(), "5\n");
|
||||
// Disconnect
|
||||
assert!(client.disconnect().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
//#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
fn test_filetransfer_scp_find() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
22,
|
||||
Some(String::from("demo")),
|
||||
Some(String::from("password"))
|
||||
)
|
||||
.is_ok());
|
||||
// Check session and scp
|
||||
assert!(client.session.is_some());
|
||||
// Search for file (let's search for pop3-*.png); there should be 2
|
||||
let search_res: Vec<FsEntry> = client.find("pop3-*.png").ok().unwrap();
|
||||
assert_eq!(search_res.len(), 2);
|
||||
// verify names
|
||||
assert_eq!(search_res[0].get_name(), "pop3-browser.png");
|
||||
assert_eq!(search_res[1].get_name(), "pop3-console-client.png");
|
||||
// Search directory
|
||||
let search_res: Vec<FsEntry> = client.find("pub").ok().unwrap();
|
||||
assert_eq!(search_res.len(), 1);
|
||||
// Disconnect
|
||||
assert!(client.disconnect().is_ok());
|
||||
// Verify err
|
||||
assert!(client.find("pippo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_recv() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -980,7 +1136,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_filetransfer_scp_recv_failed_nosuchfile() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -1015,7 +1171,7 @@ mod tests {
|
||||
/* NOTE: the server doesn't allow you to create directories
|
||||
#[test]
|
||||
fn test_filetransfer_scp_mkdir() {
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new();
|
||||
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client.connect(String::from("test.rebex.net"), 22, Some(String::from("demo")), Some(String::from("password"))).is_ok());
|
||||
let dir: String = String::from("foo");
|
||||
// Mkdir
|
||||
@@ -1027,4 +1183,31 @@ mod tests {
|
||||
assert!(client.disconnect().is_ok());
|
||||
}
|
||||
*/
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_scp_uninitialized() {
|
||||
let file: FsFile = FsFile {
|
||||
name: String::from("omar.txt"),
|
||||
abs_path: PathBuf::from("/omar.txt"),
|
||||
last_change_time: SystemTime::UNIX_EPOCH,
|
||||
last_access_time: SystemTime::UNIX_EPOCH,
|
||||
creation_time: SystemTime::UNIX_EPOCH,
|
||||
size: 0,
|
||||
ftype: Some(String::from("txt")), // File type
|
||||
readonly: true,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
};
|
||||
let mut scp: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(scp.change_dir(Path::new("/tmp")).is_err());
|
||||
assert!(scp.disconnect().is_err());
|
||||
assert!(scp.list_dir(Path::new("/tmp")).is_err());
|
||||
assert!(scp.mkdir(Path::new("/tmp")).is_err());
|
||||
assert!(scp.pwd().is_err());
|
||||
assert!(scp.stat(Path::new("/tmp")).is_err());
|
||||
assert!(scp.recv_file(&file).is_err());
|
||||
assert!(scp.send_file(&file, Path::new("/tmp/omar.txt")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,38 +2,41 @@
|
||||
//!
|
||||
//! `sftp_transfer` is the module which provides the implementation for the SFTP file transfer
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// Dependencies
|
||||
extern crate ssh2;
|
||||
|
||||
// Locals
|
||||
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
|
||||
use crate::fs::{FsDirectory, FsEntry, FsFile};
|
||||
use crate::system::sshkey_storage::SshKeyStorage;
|
||||
|
||||
// Includes
|
||||
use ssh2::{FileStat, OpenFlags, OpenType, Session, Sftp};
|
||||
use ssh2::{Channel, FileStat, OpenFlags, OpenType, Session, Sftp};
|
||||
use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
@@ -44,17 +47,19 @@ pub struct SftpFileTransfer {
|
||||
session: Option<Session>,
|
||||
sftp: Option<Sftp>,
|
||||
wrkdir: PathBuf,
|
||||
key_storage: SshKeyStorage,
|
||||
}
|
||||
|
||||
impl SftpFileTransfer {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new SftpFileTransfer
|
||||
pub fn new() -> SftpFileTransfer {
|
||||
pub fn new(key_storage: SshKeyStorage) -> SftpFileTransfer {
|
||||
SftpFileTransfer {
|
||||
session: None,
|
||||
sftp: None,
|
||||
wrkdir: PathBuf::from("~"),
|
||||
key_storage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,24 +73,24 @@ impl SftpFileTransfer {
|
||||
root.push(p);
|
||||
match self.sftp.as_ref().unwrap().realpath(root.as_path()) {
|
||||
Ok(p) => match self.sftp.as_ref().unwrap().stat(p.as_path()) {
|
||||
Ok(_) => Ok(PathBuf::from(p)),
|
||||
Ok(_) => Ok(p),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::NoSuchFileOrDirectory,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
},
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::NoSuchFileOrDirectory,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
false => match self.sftp.as_ref().unwrap().realpath(p) {
|
||||
Ok(p) => match self.sftp.as_ref().unwrap().stat(p.as_path()) {
|
||||
Ok(_) => Ok(PathBuf::from(p)),
|
||||
Ok(_) => Ok(p),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::NoSuchFileOrDirectory,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
},
|
||||
Err(_) => Err(FileTransferError::new(
|
||||
@@ -115,23 +120,21 @@ impl SftpFileTransfer {
|
||||
/// ### make_fsentry
|
||||
///
|
||||
/// Make fsentry from path and metadata
|
||||
fn make_fsentry(&self, path: &Path, metadata: &FileStat) -> FsEntry {
|
||||
fn make_fsentry(&mut self, path: &Path, metadata: &FileStat) -> FsEntry {
|
||||
// Get common parameters
|
||||
let file_name: String = String::from(path.file_name().unwrap().to_str().unwrap_or(""));
|
||||
let file_type: Option<String> = match path.extension() {
|
||||
Some(ext) => Some(String::from(ext.to_str().unwrap_or(""))),
|
||||
None => None,
|
||||
};
|
||||
let file_type: Option<String> = path
|
||||
.extension()
|
||||
.map(|ext| String::from(ext.to_str().unwrap_or("")));
|
||||
let uid: Option<u32> = metadata.uid;
|
||||
let gid: Option<u32> = metadata.gid;
|
||||
let pex: Option<(u8, u8, u8)> = match metadata.perm {
|
||||
Some(perms) => Some((
|
||||
((perms >> 6) & 0x7) as u8,
|
||||
((perms >> 3) & 0x7) as u8,
|
||||
(perms & 0x7) as u8,
|
||||
)),
|
||||
None => None,
|
||||
};
|
||||
let pex: Option<(u8, u8, u8)> = metadata.perm.map(|x| {
|
||||
(
|
||||
((x >> 6) & 0x7) as u8,
|
||||
((x >> 3) & 0x7) as u8,
|
||||
(x & 0x7) as u8,
|
||||
)
|
||||
});
|
||||
let size: u64 = metadata.size.unwrap_or(0);
|
||||
let mut atime: SystemTime = SystemTime::UNIX_EPOCH;
|
||||
atime = atime
|
||||
@@ -143,11 +146,14 @@ impl SftpFileTransfer {
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
// Check if symlink
|
||||
let is_symlink: bool = metadata.file_type().is_symlink();
|
||||
let symlink: Option<PathBuf> = match is_symlink {
|
||||
let symlink: Option<Box<FsEntry>> = match is_symlink {
|
||||
true => {
|
||||
// Read symlink
|
||||
match self.sftp.as_ref().unwrap().readlink(path) {
|
||||
Ok(p) => Some(p),
|
||||
Ok(p) => match self.stat(p.as_path()) {
|
||||
Ok(entry) => Some(Box::new(entry)),
|
||||
Err(_) => None, // Ignore errors
|
||||
},
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -162,7 +168,7 @@ impl SftpFileTransfer {
|
||||
last_access_time: atime,
|
||||
creation_time: SystemTime::UNIX_EPOCH,
|
||||
readonly: false,
|
||||
symlink: symlink,
|
||||
symlink,
|
||||
user: uid,
|
||||
group: gid,
|
||||
unix_pex: pex,
|
||||
@@ -176,13 +182,64 @@ impl SftpFileTransfer {
|
||||
last_access_time: atime,
|
||||
creation_time: SystemTime::UNIX_EPOCH,
|
||||
readonly: false,
|
||||
symlink: symlink,
|
||||
symlink,
|
||||
user: uid,
|
||||
group: gid,
|
||||
unix_pex: pex,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### perform_shell_cmd_with
|
||||
///
|
||||
/// Perform a shell command, but change directory to specified path first
|
||||
fn perform_shell_cmd_with_path(&mut self, cmd: &str) -> Result<String, FileTransferError> {
|
||||
self.perform_shell_cmd(format!("cd \"{}\"; {}", self.wrkdir.display(), cmd).as_str())
|
||||
}
|
||||
|
||||
/// ### perform_shell_cmd
|
||||
///
|
||||
/// Perform a shell command and read the output from shell
|
||||
/// This operation is, obviously, blocking.
|
||||
fn perform_shell_cmd(&mut self, cmd: &str) -> Result<String, FileTransferError> {
|
||||
match self.session.as_mut() {
|
||||
Some(session) => {
|
||||
// Create channel
|
||||
let mut channel: Channel = match session.channel_session() {
|
||||
Ok(ch) => ch,
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("Could not open channel: {}", err),
|
||||
))
|
||||
}
|
||||
};
|
||||
// Execute command
|
||||
if let Err(err) = channel.exec(cmd) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("Could not execute command \"{}\": {}", cmd, err),
|
||||
));
|
||||
}
|
||||
// Read output
|
||||
let mut output: String = String::new();
|
||||
match channel.read_to_string(&mut output) {
|
||||
Ok(_) => {
|
||||
// Wait close
|
||||
let _ = channel.wait_close();
|
||||
Ok(output)
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("Could not read output: {}", err),
|
||||
)),
|
||||
}
|
||||
}
|
||||
None => Err(FileTransferError::new(
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileTransfer for SftpFileTransfer {
|
||||
@@ -197,12 +254,34 @@ impl FileTransfer for SftpFileTransfer {
|
||||
password: Option<String>,
|
||||
) -> Result<Option<String>, FileTransferError> {
|
||||
// Setup tcp stream
|
||||
let tcp: TcpStream = match TcpStream::connect(format!("{}:{}", address, port)) {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
let socket_addresses: Vec<SocketAddr> =
|
||||
match format!("{}:{}", address, port).to_socket_addrs() {
|
||||
Ok(s) => s.collect(),
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::BadAddress,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let mut tcp: Option<TcpStream> = None;
|
||||
// Try addresses
|
||||
for socket_addr in socket_addresses.iter() {
|
||||
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
|
||||
Ok(stream) => {
|
||||
tcp = Some(stream);
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
// If stream is None, return connection timeout
|
||||
let tcp: TcpStream = match tcp {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::BadAddress,
|
||||
format!("{}", err),
|
||||
FileTransferErrorType::ConnectionError,
|
||||
String::from("Connection timeout"),
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -212,7 +291,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -222,24 +301,43 @@ impl FileTransfer for SftpFileTransfer {
|
||||
if let Err(err) = session.handshake() {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
let username: String = match username {
|
||||
Some(u) => u.clone(),
|
||||
Some(u) => u,
|
||||
None => String::from(""),
|
||||
};
|
||||
// Try authenticating with user agent
|
||||
if let Err(_) = session.userauth_agent(username.as_str()) {
|
||||
// Try authentication with password then
|
||||
if let Err(err) = session.userauth_password(
|
||||
username.as_str(),
|
||||
password.unwrap_or(String::from("")).as_str(),
|
||||
) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
format!("{}", err),
|
||||
));
|
||||
// Check if it is possible to authenticate using a RSA key
|
||||
match self
|
||||
.key_storage
|
||||
.resolve(address.as_str(), username.as_str())
|
||||
{
|
||||
Some(rsa_key) => {
|
||||
// Authenticate with RSA key
|
||||
if let Err(err) = session.userauth_pubkey_file(
|
||||
username.as_str(),
|
||||
None,
|
||||
rsa_key.as_path(),
|
||||
password.as_deref(),
|
||||
) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Proceeed with username/password authentication
|
||||
if let Err(err) = session.userauth_password(
|
||||
username.as_str(),
|
||||
password.unwrap_or_else(|| String::from("")).as_str(),
|
||||
) {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::AuthenticationFailed,
|
||||
err.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set blocking to true
|
||||
@@ -250,7 +348,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
@@ -260,15 +358,12 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
// Set session
|
||||
let banner: Option<String> = match session.banner() {
|
||||
Some(s) => Some(String::from(s)),
|
||||
None => None,
|
||||
};
|
||||
let banner: Option<String> = session.banner().map(String::from);
|
||||
self.session = Some(session);
|
||||
// Set sftp
|
||||
self.sftp = Some(sftp);
|
||||
@@ -291,7 +386,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ConnectionError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -339,6 +434,16 @@ 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,
|
||||
))
|
||||
}
|
||||
|
||||
/// ### list_dir
|
||||
///
|
||||
/// List directory entries
|
||||
@@ -352,12 +457,10 @@ impl FileTransfer for SftpFileTransfer {
|
||||
};
|
||||
// Get files
|
||||
match sftp.readdir(dir.as_path()) {
|
||||
Err(err) => {
|
||||
return Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::DirStatFailed,
|
||||
format!("{}", err),
|
||||
))
|
||||
}
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::DirStatFailed,
|
||||
err.to_string(),
|
||||
)),
|
||||
Ok(files) => {
|
||||
// Allocate vector
|
||||
let mut entries: Vec<FsEntry> = Vec::with_capacity(files.len());
|
||||
@@ -387,7 +490,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::FileCreateDenied,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -414,7 +517,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::PexError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -435,7 +538,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::PexError,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -454,15 +557,12 @@ impl FileTransfer for SftpFileTransfer {
|
||||
// Resolve destination path
|
||||
let abs_dst: PathBuf = self.get_abs_path(dst);
|
||||
// Get abs path of entry
|
||||
let abs_src: PathBuf = match file {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
};
|
||||
let abs_src: PathBuf = file.get_abs_path();
|
||||
match sftp.rename(abs_src.as_path(), abs_dst.as_path(), None) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::FileCreateDenied,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -485,7 +585,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(metadata) => Ok(self.make_fsentry(dir.as_path(), &metadata)),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::NoSuchFileOrDirectory,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -495,6 +595,24 @@ impl FileTransfer for SftpFileTransfer {
|
||||
}
|
||||
}
|
||||
|
||||
/// ### exec
|
||||
///
|
||||
/// Execute a command on remote host
|
||||
fn exec(&mut self, cmd: &str) -> Result<String, FileTransferError> {
|
||||
match self.is_connected() {
|
||||
true => match self.perform_shell_cmd_with_path(cmd) {
|
||||
Ok(output) => Ok(output),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::ProtocolError,
|
||||
err.to_string(),
|
||||
)),
|
||||
},
|
||||
false => Err(FileTransferError::new(
|
||||
FileTransferErrorType::UninitializedSession,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### send_file
|
||||
///
|
||||
/// Send file to remote
|
||||
@@ -525,7 +643,7 @@ impl FileTransfer for SftpFileTransfer {
|
||||
Ok(file) => Ok(Box::new(BufWriter::with_capacity(65536, file))),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::FileCreateDenied,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -548,10 +666,10 @@ impl FileTransfer for SftpFileTransfer {
|
||||
};
|
||||
// Open remote file
|
||||
match sftp.open(remote_path.as_path()) {
|
||||
Ok(file) => Ok(Box::new(BufReader::with_capacity(8192, file))),
|
||||
Ok(file) => Ok(Box::new(BufReader::with_capacity(65536, file))),
|
||||
Err(err) => Err(FileTransferError::new_ex(
|
||||
FileTransferErrorType::NoSuchFileOrDirectory,
|
||||
format!("{}", err),
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -586,7 +704,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_new() {
|
||||
let client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client.session.is_none());
|
||||
assert!(client.sftp.is_none());
|
||||
assert_eq!(client.wrkdir, PathBuf::from("~"));
|
||||
@@ -595,7 +713,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_connect() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert_eq!(client.is_connected(), false);
|
||||
assert!(client
|
||||
.connect(
|
||||
@@ -617,7 +735,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_bad_auth() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -630,7 +748,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_no_credentials() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(String::from("test.rebex.net"), 22, None, None)
|
||||
.is_err());
|
||||
@@ -638,7 +756,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_bad_server() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("mybadserver.veryverybad.awful"),
|
||||
@@ -651,7 +769,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_pwd() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -672,7 +790,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_cwd() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -697,9 +815,44 @@ mod tests {
|
||||
assert!(client.disconnect().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_copy() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
22,
|
||||
Some(String::from("demo")),
|
||||
Some(String::from("password"))
|
||||
)
|
||||
.is_ok());
|
||||
// Check session and sftp
|
||||
assert!(client.session.is_some());
|
||||
assert!(client.sftp.is_some());
|
||||
assert_eq!(client.wrkdir, PathBuf::from("/"));
|
||||
// Copy
|
||||
let file: FsFile = FsFile {
|
||||
name: String::from("readme.txt"),
|
||||
abs_path: PathBuf::from("/readme.txt"),
|
||||
last_change_time: SystemTime::UNIX_EPOCH,
|
||||
last_access_time: SystemTime::UNIX_EPOCH,
|
||||
creation_time: SystemTime::UNIX_EPOCH,
|
||||
size: 0,
|
||||
ftype: Some(String::from("txt")), // File type
|
||||
readonly: true,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
};
|
||||
assert!(client
|
||||
.copy(&FsEntry::File(file), &Path::new("/tmp/dest.txt"))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_cwd_error() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -718,11 +871,14 @@ mod tests {
|
||||
.is_err());
|
||||
// Disconnect
|
||||
assert!(client.disconnect().is_ok());
|
||||
assert!(client
|
||||
.change_dir(PathBuf::from("gomar/pett").as_path())
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_ls() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -741,11 +897,13 @@ mod tests {
|
||||
assert_eq!(files.len(), 3); // There are 3 files
|
||||
// Disconnect
|
||||
assert!(client.disconnect().is_ok());
|
||||
// Verify err
|
||||
assert!(client.list_dir(pwd.as_path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_stat() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -769,9 +927,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_exec() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
22,
|
||||
Some(String::from("demo")),
|
||||
Some(String::from("password"))
|
||||
)
|
||||
.is_ok());
|
||||
// Check session and scp
|
||||
assert!(client.session.is_some());
|
||||
// Exec
|
||||
assert_eq!(client.exec("echo 5").ok().unwrap().as_str(), "5\n");
|
||||
// Disconnect
|
||||
assert!(client.disconnect().is_ok());
|
||||
// Verify err
|
||||
assert!(client.exec("echo 1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_find() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
22,
|
||||
Some(String::from("demo")),
|
||||
Some(String::from("password"))
|
||||
)
|
||||
.is_ok());
|
||||
// Check session and scp
|
||||
assert!(client.session.is_some());
|
||||
// Search for file (let's search for pop3-*.png); there should be 2
|
||||
let search_res: Vec<FsEntry> = client.find("pop3-*.png").ok().unwrap();
|
||||
assert_eq!(search_res.len(), 2);
|
||||
// verify names
|
||||
assert_eq!(search_res[0].get_name(), "pop3-browser.png");
|
||||
assert_eq!(search_res[1].get_name(), "pop3-console-client.png");
|
||||
// Search directory
|
||||
let search_res: Vec<FsEntry> = client.find("pub").ok().unwrap();
|
||||
assert_eq!(search_res.len(), 1);
|
||||
// Disconnect
|
||||
assert!(client.disconnect().is_ok());
|
||||
// Verify err
|
||||
assert!(client.find("pippo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_recv() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -805,7 +1012,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_recv_failed_nosuchfile() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client
|
||||
.connect(
|
||||
String::from("test.rebex.net"),
|
||||
@@ -843,7 +1050,7 @@ mod tests {
|
||||
/* NOTE: the server doesn't allow you to create directories
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_mkdir() {
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new();
|
||||
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(client.connect(String::from("test.rebex.net"), 22, Some(String::from("demo")), Some(String::from("password"))).is_ok());
|
||||
let dir: String = String::from("foo");
|
||||
// Mkdir
|
||||
@@ -855,4 +1062,31 @@ mod tests {
|
||||
assert!(client.disconnect().is_ok());
|
||||
}
|
||||
*/
|
||||
|
||||
#[test]
|
||||
fn test_filetransfer_sftp_uninitialized() {
|
||||
let file: FsFile = FsFile {
|
||||
name: String::from("omar.txt"),
|
||||
abs_path: PathBuf::from("/omar.txt"),
|
||||
last_change_time: SystemTime::UNIX_EPOCH,
|
||||
last_access_time: SystemTime::UNIX_EPOCH,
|
||||
creation_time: SystemTime::UNIX_EPOCH,
|
||||
size: 0,
|
||||
ftype: Some(String::from("txt")), // File type
|
||||
readonly: true,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
};
|
||||
let mut sftp: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
|
||||
assert!(sftp.change_dir(Path::new("/tmp")).is_err());
|
||||
assert!(sftp.disconnect().is_err());
|
||||
assert!(sftp.list_dir(Path::new("/tmp")).is_err());
|
||||
assert!(sftp.mkdir(Path::new("/tmp")).is_err());
|
||||
assert!(sftp.pwd().is_err());
|
||||
assert!(sftp.stat(Path::new("/tmp")).is_err());
|
||||
assert!(sftp.recv_file(&file).is_err());
|
||||
assert!(sftp.send_file(&file, Path::new("/tmp/omar.txt")).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
145
src/fs/explorer/builder.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! ## Builder
|
||||
//!
|
||||
//! `builder` is the module which provides a builder for FileExplorer
|
||||
|
||||
/**
|
||||
* 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::formatter::Formatter;
|
||||
use super::{ExplorerOpts, FileExplorer, FileSorting, GroupDirs};
|
||||
// Ext
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// ## FileExplorerBuilder
|
||||
///
|
||||
/// Struct used to create a `FileExplorer`
|
||||
pub struct FileExplorerBuilder {
|
||||
explorer: Option<FileExplorer>,
|
||||
}
|
||||
|
||||
impl FileExplorerBuilder {
|
||||
/// ### new
|
||||
///
|
||||
/// Build a new `FileExplorerBuilder`
|
||||
pub fn new() -> Self {
|
||||
FileExplorerBuilder {
|
||||
explorer: Some(FileExplorer::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### build
|
||||
///
|
||||
/// Take FileExplorer out of builder
|
||||
pub fn build(&mut self) -> FileExplorer {
|
||||
self.explorer.take().unwrap()
|
||||
}
|
||||
|
||||
/// ### with_hidden_files
|
||||
///
|
||||
/// Enable HIDDEN_FILES option
|
||||
pub fn with_hidden_files(&mut self, val: bool) -> &mut FileExplorerBuilder {
|
||||
if let Some(e) = self.explorer.as_mut() {
|
||||
match val {
|
||||
true => e.opts.insert(ExplorerOpts::SHOW_HIDDEN_FILES),
|
||||
false => e.opts.remove(ExplorerOpts::SHOW_HIDDEN_FILES),
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_file_sorting
|
||||
///
|
||||
/// Set sorting method
|
||||
pub fn with_file_sorting(&mut self, sorting: FileSorting) -> &mut FileExplorerBuilder {
|
||||
if let Some(e) = self.explorer.as_mut() {
|
||||
e.sort_by(sorting);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_dirs_first
|
||||
///
|
||||
/// Enable DIRS_FIRST option
|
||||
pub fn with_group_dirs(&mut self, group_dirs: Option<GroupDirs>) -> &mut FileExplorerBuilder {
|
||||
if let Some(e) = self.explorer.as_mut() {
|
||||
e.group_dirs_by(group_dirs);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_stack_size
|
||||
///
|
||||
/// Set stack size for FileExplorer
|
||||
pub fn with_stack_size(&mut self, sz: usize) -> &mut FileExplorerBuilder {
|
||||
if let Some(e) = self.explorer.as_mut() {
|
||||
e.stack_size = sz;
|
||||
e.dirstack = VecDeque::with_capacity(sz);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// ### with_formatter
|
||||
///
|
||||
/// Set formatter for FileExplorer
|
||||
pub fn with_formatter(&mut self, fmt_str: Option<&str>) -> &mut FileExplorerBuilder {
|
||||
if let Some(e) = self.explorer.as_mut() {
|
||||
if let Some(fmt_str) = fmt_str {
|
||||
e.fmt = Formatter::new(fmt_str);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_builder_new_default() {
|
||||
let explorer: FileExplorer = FileExplorerBuilder::new().build();
|
||||
// Verify
|
||||
assert!(!explorer.opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES));
|
||||
assert_eq!(explorer.file_sorting, FileSorting::ByName); // Default
|
||||
assert_eq!(explorer.group_dirs, None);
|
||||
assert_eq!(explorer.stack_size, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_builder_new_all() {
|
||||
let explorer: FileExplorer = FileExplorerBuilder::new()
|
||||
.with_file_sorting(FileSorting::ByModifyTime)
|
||||
.with_group_dirs(Some(GroupDirs::First))
|
||||
.with_hidden_files(true)
|
||||
.with_stack_size(24)
|
||||
.with_formatter(Some("{NAME}"))
|
||||
.build();
|
||||
// Verify
|
||||
assert!(explorer.opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES));
|
||||
assert_eq!(explorer.file_sorting, FileSorting::ByModifyTime); // Default
|
||||
assert_eq!(explorer.group_dirs, Some(GroupDirs::First));
|
||||
assert_eq!(explorer.stack_size, 24);
|
||||
}
|
||||
}
|
||||
910
src/fs/explorer/formatter.rs
Normal file
@@ -0,0 +1,910 @@
|
||||
//! ## Formatter
|
||||
//!
|
||||
//! `formatter` is the module which provides formatting utilities for `FileExplorer`
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Deps
|
||||
extern crate bytesize;
|
||||
extern crate regex;
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
extern crate users;
|
||||
// Locals
|
||||
use super::FsEntry;
|
||||
use crate::utils::fmt::{fmt_path_elide, fmt_pex, fmt_time};
|
||||
// Ext
|
||||
use bytesize::ByteSize;
|
||||
use regex::Regex;
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
use users::{get_group_by_gid, get_user_by_uid};
|
||||
// Types
|
||||
// FmtCallback: Formatter, fsentry: &FsEntry, cur_str, prefix, length, extra
|
||||
type FmtCallback = fn(&Formatter, &FsEntry, &str, &str, Option<&usize>, Option<&String>) -> String;
|
||||
|
||||
// Keys
|
||||
const FMT_KEY_ATIME: &str = "ATIME";
|
||||
const FMT_KEY_CTIME: &str = "CTIME";
|
||||
const FMT_KEY_GROUP: &str = "GROUP";
|
||||
const FMT_KEY_MTIME: &str = "MTIME";
|
||||
const FMT_KEY_NAME: &str = "NAME";
|
||||
const FMT_KEY_PEX: &str = "PEX";
|
||||
const FMT_KEY_SIZE: &str = "SIZE";
|
||||
const FMT_KEY_SYMLINK: &str = "SYMLINK";
|
||||
const FMT_KEY_USER: &str = "USER";
|
||||
// Default
|
||||
const FMT_DEFAULT_STX: &str = "{NAME} {PEX} {USER} {SIZE} {MTIME}";
|
||||
// Regex
|
||||
lazy_static! {
|
||||
/**
|
||||
* Regex matches:
|
||||
* - group 0: KEY NAME
|
||||
* - group 1?: LENGTH
|
||||
* - group 2?: EXTRA
|
||||
*/
|
||||
static ref FMT_KEY_REGEX: Regex = Regex::new(r"\{(.*?)\}").ok().unwrap();
|
||||
static ref FMT_ATTR_REGEX: Regex = Regex::new(r"(?:([A-Z]+))(:?([0-9]+))?(:?(.+))?").ok().unwrap();
|
||||
}
|
||||
|
||||
/// ## CallChainBlock
|
||||
///
|
||||
/// Call Chain block is a block in a chain of functions which are called in order to format the FsEntry.
|
||||
/// A callChain is instantiated starting from the Formatter syntax and the regex, once the groups are found
|
||||
/// a chain of function is made using the Formatters method.
|
||||
/// This method provides an extremely fast way to format fs entries
|
||||
struct CallChainBlock {
|
||||
func: FmtCallback,
|
||||
prefix: String,
|
||||
fmt_len: Option<usize>,
|
||||
fmt_extra: Option<String>,
|
||||
next_block: Option<Box<CallChainBlock>>,
|
||||
}
|
||||
|
||||
impl CallChainBlock {
|
||||
/// ### new
|
||||
///
|
||||
/// Create a new `CallChainBlock`
|
||||
pub fn new(
|
||||
func: FmtCallback,
|
||||
prefix: String,
|
||||
fmt_len: Option<usize>,
|
||||
fmt_extra: Option<String>,
|
||||
) -> Self {
|
||||
CallChainBlock {
|
||||
func,
|
||||
prefix,
|
||||
fmt_len,
|
||||
fmt_extra,
|
||||
next_block: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### next
|
||||
///
|
||||
/// Call next callback in the CallChain
|
||||
pub fn next(&self, fmt: &Formatter, fsentry: &FsEntry, cur_str: &str) -> String {
|
||||
// Call func
|
||||
let new_str: String = (self.func)(
|
||||
fmt,
|
||||
fsentry,
|
||||
cur_str,
|
||||
self.prefix.as_str(),
|
||||
self.fmt_len.as_ref(),
|
||||
self.fmt_extra.as_ref(),
|
||||
);
|
||||
// If next is some, call next, otherwise (END OF CHAIN) return new_str
|
||||
match &self.next_block {
|
||||
Some(block) => block.next(fmt, fsentry, new_str.as_str()),
|
||||
None => new_str,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### push
|
||||
///
|
||||
/// Push func to the last element in the Call chain
|
||||
pub fn push(
|
||||
&mut self,
|
||||
func: FmtCallback,
|
||||
prefix: String,
|
||||
fmt_len: Option<usize>,
|
||||
fmt_extra: Option<String>,
|
||||
) {
|
||||
// Call recursively until an element with next_block equal to None is found
|
||||
match &mut self.next_block {
|
||||
None => {
|
||||
self.next_block = Some(Box::new(CallChainBlock::new(
|
||||
func, prefix, fmt_len, fmt_extra,
|
||||
)))
|
||||
}
|
||||
Some(block) => block.push(func, prefix, fmt_len, fmt_extra),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ## Formatter
|
||||
///
|
||||
/// Formatter takes care of formatting FsEntries according to the provided keys.
|
||||
/// Formatting is performed using the `CallChainBlock`, which composed makes a Call Chain. This method is extremely fast compared to match the format groups
|
||||
/// at each fmt call.
|
||||
pub struct Formatter {
|
||||
call_chain: CallChainBlock,
|
||||
}
|
||||
|
||||
impl Default for Formatter {
|
||||
/// ### default
|
||||
///
|
||||
/// Instantiates a Formatter with the default fmt syntax
|
||||
fn default() -> Self {
|
||||
Formatter {
|
||||
call_chain: Self::make_callchain(FMT_DEFAULT_STX),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Formatter {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new `Formatter` with the provided format string
|
||||
pub fn new(fmt_str: &str) -> Self {
|
||||
Formatter {
|
||||
call_chain: Self::make_callchain(fmt_str),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### fmt
|
||||
///
|
||||
/// Format fsentry
|
||||
pub fn fmt(&self, fsentry: &FsEntry) -> String {
|
||||
// Execute callchain blocks
|
||||
self.call_chain.next(self, fsentry, "")
|
||||
}
|
||||
|
||||
// Fmt methods
|
||||
|
||||
/// ### fmt_atime
|
||||
///
|
||||
/// Format last access time
|
||||
fn fmt_atime(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
fmt_len: Option<&usize>,
|
||||
fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Get date (use extra args as format or default "%b %d %Y %H:%M")
|
||||
let datetime: String = fmt_time(
|
||||
fsentry.get_last_access_time(),
|
||||
match fmt_extra {
|
||||
Some(fmt) => fmt.as_ref(),
|
||||
None => "%b %d %Y %H:%M",
|
||||
},
|
||||
);
|
||||
// Add to cur str, prefix and the key value
|
||||
format!(
|
||||
"{}{}{:0width$}",
|
||||
cur_str,
|
||||
prefix,
|
||||
datetime,
|
||||
width = fmt_len.unwrap_or(&17)
|
||||
)
|
||||
}
|
||||
|
||||
/// ### fmt_ctime
|
||||
///
|
||||
/// Format creation time
|
||||
fn fmt_ctime(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
fmt_len: Option<&usize>,
|
||||
fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Get date
|
||||
let datetime: String = fmt_time(
|
||||
fsentry.get_creation_time(),
|
||||
match fmt_extra {
|
||||
Some(fmt) => fmt.as_ref(),
|
||||
None => "%b %d %Y %H:%M",
|
||||
},
|
||||
);
|
||||
// Add to cur str, prefix and the key value
|
||||
format!(
|
||||
"{}{}{:0width$}",
|
||||
cur_str,
|
||||
prefix,
|
||||
datetime,
|
||||
width = fmt_len.unwrap_or(&17)
|
||||
)
|
||||
}
|
||||
|
||||
/// ### fmt_group
|
||||
///
|
||||
/// Format owner group
|
||||
fn fmt_group(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Get username
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
let group: String = match fsentry.get_group() {
|
||||
Some(gid) => match get_group_by_gid(gid) {
|
||||
Some(user) => user.name().to_string_lossy().to_string(),
|
||||
None => gid.to_string(),
|
||||
},
|
||||
None => 0.to_string(),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let group: String = match fsentry.get_group() {
|
||||
Some(gid) => gid.to_string(),
|
||||
None => 0.to_string(),
|
||||
};
|
||||
// Add to cur str, prefix and the key value
|
||||
format!(
|
||||
"{}{}{:0width$}",
|
||||
cur_str,
|
||||
prefix,
|
||||
group,
|
||||
width = fmt_len.unwrap_or(&12)
|
||||
)
|
||||
}
|
||||
|
||||
/// ### fmt_mtime
|
||||
///
|
||||
/// Format last change time
|
||||
fn fmt_mtime(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
fmt_len: Option<&usize>,
|
||||
fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Get date
|
||||
let datetime: String = fmt_time(
|
||||
fsentry.get_last_change_time(),
|
||||
match fmt_extra {
|
||||
Some(fmt) => fmt.as_ref(),
|
||||
None => "%b %d %Y %H:%M",
|
||||
},
|
||||
);
|
||||
// Add to cur str, prefix and the key value
|
||||
format!(
|
||||
"{}{}{:0width$}",
|
||||
cur_str,
|
||||
prefix,
|
||||
datetime,
|
||||
width = fmt_len.unwrap_or(&17)
|
||||
)
|
||||
}
|
||||
|
||||
/// ### fmt_name
|
||||
///
|
||||
/// Format file name
|
||||
fn fmt_name(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Get file name (or elide if too long)
|
||||
let file_len: usize = match fmt_len {
|
||||
Some(l) => *l,
|
||||
None => 24,
|
||||
};
|
||||
let name: &str = fsentry.get_name();
|
||||
let last_idx: usize = match fsentry.is_dir() {
|
||||
// NOTE: For directories is 19, since we push '/' to name
|
||||
true => file_len - 5,
|
||||
false => file_len - 4,
|
||||
};
|
||||
let mut name: String = match name.len() >= file_len {
|
||||
false => name.to_string(),
|
||||
true => format!("{}...", &name[0..last_idx]),
|
||||
};
|
||||
if fsentry.is_dir() {
|
||||
name.push('/');
|
||||
}
|
||||
// Add to cur str, prefix and the key value
|
||||
format!("{}{}{:0width$}", cur_str, prefix, name, width = file_len)
|
||||
}
|
||||
|
||||
/// ### fmt_pex
|
||||
///
|
||||
/// Format file permissions
|
||||
fn fmt_pex(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
_fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Create mode string
|
||||
let mut pex: String = String::with_capacity(10);
|
||||
let file_type: char = match fsentry.is_symlink() {
|
||||
true => 'l',
|
||||
false => match fsentry.is_dir() {
|
||||
true => 'd',
|
||||
false => '-',
|
||||
},
|
||||
};
|
||||
pex.push(file_type);
|
||||
match fsentry.get_unix_pex() {
|
||||
None => pex.push_str("?????????"),
|
||||
Some((owner, group, others)) => pex.push_str(fmt_pex(owner, group, others).as_str()),
|
||||
}
|
||||
// Add to cur str, prefix and the key value
|
||||
format!("{}{}{:10}", cur_str, prefix, pex)
|
||||
}
|
||||
|
||||
/// ### fmt_size
|
||||
///
|
||||
/// Format file size
|
||||
fn fmt_size(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
_fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
if fsentry.is_file() {
|
||||
// Get byte size
|
||||
let size: ByteSize = ByteSize(fsentry.get_size() as u64);
|
||||
// Add to cur str, prefix and the key value
|
||||
format!("{}{}{:10}", cur_str, prefix, size.to_string())
|
||||
} else {
|
||||
// Add to cur str, prefix and the key value
|
||||
format!("{}{} ", cur_str, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
/// ### fmt_symlink
|
||||
///
|
||||
/// Format file symlink (if any)
|
||||
fn fmt_symlink(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Get file name (or elide if too long)
|
||||
let file_len: usize = match fmt_len {
|
||||
Some(l) => *l,
|
||||
None => 21,
|
||||
};
|
||||
// Replace `FMT_KEY_NAME` with name
|
||||
match fsentry.is_symlink() {
|
||||
false => format!("{}{} ", cur_str, prefix),
|
||||
true => format!(
|
||||
"{}{}-> {:0width$}",
|
||||
cur_str,
|
||||
prefix,
|
||||
fmt_path_elide(
|
||||
fsentry.get_realfile().get_abs_path().as_path(),
|
||||
file_len - 1
|
||||
),
|
||||
width = file_len
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### fmt_user
|
||||
///
|
||||
/// Format owner user
|
||||
fn fmt_user(
|
||||
&self,
|
||||
fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
_fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Get username
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
let username: String = match fsentry.get_user() {
|
||||
Some(uid) => match get_user_by_uid(uid) {
|
||||
Some(user) => user.name().to_string_lossy().to_string(),
|
||||
None => uid.to_string(),
|
||||
},
|
||||
None => 0.to_string(),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let username: String = match fsentry.get_user() {
|
||||
Some(uid) => uid.to_string(),
|
||||
None => 0.to_string(),
|
||||
};
|
||||
// Add to cur str, prefix and the key value
|
||||
format!("{}{}{:12}", cur_str, prefix, username)
|
||||
}
|
||||
|
||||
/// ### fmt_fallback
|
||||
///
|
||||
/// Fallback function in case the format key is unknown
|
||||
/// It does nothing, just returns cur_str
|
||||
fn fmt_fallback(
|
||||
&self,
|
||||
_fsentry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
_fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
// Add to cur str and prefix
|
||||
format!("{}{}", cur_str, prefix)
|
||||
}
|
||||
|
||||
// Static
|
||||
|
||||
/// ### make_callchain
|
||||
///
|
||||
/// Make a callchain starting from the fmt str
|
||||
fn make_callchain(fmt_str: &str) -> CallChainBlock {
|
||||
// Init chain block
|
||||
let mut callchain: Option<CallChainBlock> = None;
|
||||
// Track index of the last match found, to get the prefix for each token
|
||||
let mut last_index: usize = 0;
|
||||
// Match fmt str against regex
|
||||
for regex_match in FMT_KEY_REGEX.captures_iter(fmt_str) {
|
||||
// Get match index (unwrap is safe, since always exists)
|
||||
let index: usize = fmt_str.find(®ex_match[0]).unwrap();
|
||||
// Get prefix
|
||||
let prefix: String = String::from(&fmt_str[last_index..index]);
|
||||
// Increment last index (sum prefix lenght and the length of the key)
|
||||
last_index += prefix.len() + regex_match[0].len();
|
||||
// Match attributes
|
||||
match FMT_ATTR_REGEX.captures(®ex_match[1]) {
|
||||
Some(regex_match) => {
|
||||
// Match group 0 (which is name)
|
||||
let callback: FmtCallback = match ®ex_match.get(1) {
|
||||
Some(key) => match key.as_str() {
|
||||
FMT_KEY_ATIME => Self::fmt_atime,
|
||||
FMT_KEY_CTIME => Self::fmt_ctime,
|
||||
FMT_KEY_GROUP => Self::fmt_group,
|
||||
FMT_KEY_MTIME => Self::fmt_mtime,
|
||||
FMT_KEY_NAME => Self::fmt_name,
|
||||
FMT_KEY_PEX => Self::fmt_pex,
|
||||
FMT_KEY_SIZE => Self::fmt_size,
|
||||
FMT_KEY_SYMLINK => Self::fmt_symlink,
|
||||
FMT_KEY_USER => Self::fmt_user,
|
||||
_ => Self::fmt_fallback,
|
||||
},
|
||||
None => Self::fmt_fallback,
|
||||
};
|
||||
// Match format length: group 3
|
||||
let fmt_len: Option<usize> = match ®ex_match.get(3) {
|
||||
Some(len) => match len.as_str().parse::<usize>() {
|
||||
Ok(len) => Some(len),
|
||||
Err(_) => None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
// Match format extra: group 2 + 1
|
||||
let fmt_extra: Option<String> = regex_match
|
||||
.get(5)
|
||||
.as_ref()
|
||||
.map(|extra| extra.as_str().to_string());
|
||||
// Create a callchain or push new element to its back
|
||||
match callchain.as_mut() {
|
||||
None => {
|
||||
callchain =
|
||||
Some(CallChainBlock::new(callback, prefix, fmt_len, fmt_extra))
|
||||
}
|
||||
Some(chain_block) => chain_block.push(callback, prefix, fmt_len, fmt_extra),
|
||||
}
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
// Finalize and return
|
||||
match callchain {
|
||||
Some(callchain) => callchain,
|
||||
None => CallChainBlock::new(Self::fmt_fallback, String::new(), None, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::fs::{FsDirectory, FsFile};
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_formatter_callchain() {
|
||||
// Make a dummy formatter
|
||||
let dummy_formatter: Formatter = Formatter::new("");
|
||||
// Make a dummy entry
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
let dummy_entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
let prefix: String = String::from("h");
|
||||
let mut callchain: CallChainBlock = CallChainBlock::new(dummy_fmt, prefix, None, None);
|
||||
assert!(callchain.next_block.is_none());
|
||||
assert_eq!(callchain.prefix, String::from("h"));
|
||||
// Execute
|
||||
assert_eq!(
|
||||
callchain.next(&dummy_formatter, &dummy_entry, ""),
|
||||
String::from("hA")
|
||||
);
|
||||
// Push 4 new blocks
|
||||
callchain.push(dummy_fmt, String::from("h"), None, None);
|
||||
callchain.push(dummy_fmt, String::from("h"), None, None);
|
||||
callchain.push(dummy_fmt, String::from("h"), None, None);
|
||||
callchain.push(dummy_fmt, String::from("h"), None, None);
|
||||
// Verify
|
||||
assert_eq!(
|
||||
callchain.next(&dummy_formatter, &dummy_entry, ""),
|
||||
String::from("hAhAhAhAhA")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_formatter_format_files() {
|
||||
// Make default
|
||||
let formatter: Formatter = Formatter::default();
|
||||
// Experiments :D
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"bar.txt -rw-r--r-- root 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"bar.txt -rw-r--r-- 0 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
// Elide name
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("piroparoporoperoperupupu.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"piroparoporoperoperu... -rw-r--r-- root 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"piroparoporoperoperu... -rw-r--r-- 0 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
// No pex
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: None, // UNIX only
|
||||
});
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"bar.txt -????????? root 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"bar.txt -????????? 0 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
// No user
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: None, // UNIX only
|
||||
});
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"bar.txt -????????? 0 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"bar.txt -????????? 0 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_formatter_format_dirs() {
|
||||
// Make default
|
||||
let formatter: Formatter = Formatter::default();
|
||||
// Experiments :D
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
let entry: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("projects"),
|
||||
abs_path: PathBuf::from("/home/cvisintin/projects"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
});
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"projects/ drwxr-xr-x root {}",
|
||||
fmt_time(t_now, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"projects/ drwxr-xr-x 0 {}",
|
||||
fmt_time(t_now, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
// No pex, no user
|
||||
let entry: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("projects"),
|
||||
abs_path: PathBuf::from("/home/cvisintin/projects"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: None, // UNIX only
|
||||
});
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"projects/ d????????? 0 {}",
|
||||
fmt_time(t_now, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
"projects/ d????????? 0 {}",
|
||||
fmt_time(t_now, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_formatter_all_together_now() {
|
||||
let formatter: Formatter =
|
||||
Formatter::new("{NAME:16} {SYMLINK:12} {GROUP} {USER} {PEX} {SIZE} {ATIME:20:%a %b %d %Y %H:%M} {CTIME:20:%a %b %d %Y %H:%M} {MTIME:20:%a %b %d %Y %H:%M}");
|
||||
// Directory (with symlink)
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let pointer: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("project.info"),
|
||||
abs_path: PathBuf::from("/project.info"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: None, // UNIX only
|
||||
unix_pex: None, // UNIX only
|
||||
});
|
||||
let entry: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("projects"),
|
||||
abs_path: PathBuf::from("/home/cvisintin/project"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
readonly: false,
|
||||
symlink: Some(Box::new(pointer)), // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: None, // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
});
|
||||
assert_eq!(formatter.fmt(&entry), format!(
|
||||
"projects/ -> project.info 0 0 lrwxr-xr-x {} {} {}",
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
));
|
||||
// Directory without symlink
|
||||
let entry: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("projects"),
|
||||
abs_path: PathBuf::from("/home/cvisintin/project"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: None, // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
});
|
||||
assert_eq!(formatter.fmt(&entry), format!(
|
||||
"projects/ 0 0 drwxr-xr-x {} {} {}",
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
));
|
||||
// File with symlink
|
||||
let pointer: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("project.info"),
|
||||
abs_path: PathBuf::from("/project.info"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: None, // UNIX only
|
||||
unix_pex: None, // UNIX only
|
||||
});
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: Some(Box::new(pointer)), // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: None, // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
assert_eq!(formatter.fmt(&entry), format!(
|
||||
"bar.txt -> project.info 0 0 lrw-r--r-- 8.2 KB {} {} {}",
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
));
|
||||
// File without symlink
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: None, // UNIX only
|
||||
group: None, // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
assert_eq!(formatter.fmt(&entry), format!(
|
||||
"bar.txt 0 0 -rw-r--r-- 8.2 KB {} {} {}",
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
fmt_time(t, "%a %b %d %Y %H:%M"),
|
||||
));
|
||||
}
|
||||
|
||||
/// ### dummy_fmt
|
||||
///
|
||||
/// Dummy formatter, just yelds an 'A' at the end of the current string
|
||||
fn dummy_fmt(
|
||||
_fmt: &Formatter,
|
||||
_entry: &FsEntry,
|
||||
cur_str: &str,
|
||||
prefix: &str,
|
||||
_fmt_len: Option<&usize>,
|
||||
_fmt_extra: Option<&String>,
|
||||
) -> String {
|
||||
format!("{}{}A", cur_str, prefix)
|
||||
}
|
||||
}
|
||||
718
src/fs/explorer/mod.rs
Normal file
@@ -0,0 +1,718 @@
|
||||
//! ## Explorer
|
||||
//!
|
||||
//! `explorer` is the module which provides an Helper in handling Directory status through
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// Mods
|
||||
pub(crate) mod builder;
|
||||
mod formatter;
|
||||
// Deps
|
||||
extern crate bitflags;
|
||||
// Locals
|
||||
use super::FsEntry;
|
||||
use formatter::Formatter;
|
||||
// Ext
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::string::ToString;
|
||||
|
||||
bitflags! {
|
||||
/// ## ExplorerOpts
|
||||
///
|
||||
/// ExplorerOpts are bit options which provides different behaviours to `FileExplorer`
|
||||
pub(crate) struct ExplorerOpts: u32 {
|
||||
const SHOW_HIDDEN_FILES = 0b00000001;
|
||||
}
|
||||
}
|
||||
|
||||
/// ## FileSorting
|
||||
///
|
||||
/// FileSorting defines the criteria for sorting files
|
||||
#[derive(Copy, Clone, PartialEq, std::fmt::Debug)]
|
||||
pub enum FileSorting {
|
||||
ByName,
|
||||
ByModifyTime,
|
||||
ByCreationTime,
|
||||
BySize,
|
||||
}
|
||||
|
||||
/// ## GroupDirs
|
||||
///
|
||||
/// GroupDirs defines how directories should be grouped in sorting files
|
||||
#[derive(PartialEq, std::fmt::Debug)]
|
||||
pub enum GroupDirs {
|
||||
First,
|
||||
Last,
|
||||
}
|
||||
|
||||
/// ## FileExplorer
|
||||
///
|
||||
/// File explorer states
|
||||
pub struct FileExplorer {
|
||||
pub wrkdir: PathBuf, // Current directory
|
||||
pub(crate) dirstack: VecDeque<PathBuf>, // Stack of visited directory (max 16)
|
||||
pub(crate) stack_size: usize, // Directory stack size
|
||||
pub(crate) file_sorting: FileSorting, // File sorting criteria
|
||||
pub(crate) group_dirs: Option<GroupDirs>, // If Some, defines how to group directories
|
||||
pub(crate) opts: ExplorerOpts, // Explorer options
|
||||
pub(crate) fmt: Formatter, // FsEntry formatter
|
||||
files: Vec<FsEntry>, // Files in directory
|
||||
}
|
||||
|
||||
impl Default for FileExplorer {
|
||||
fn default() -> Self {
|
||||
FileExplorer {
|
||||
wrkdir: PathBuf::from("/"),
|
||||
dirstack: VecDeque::with_capacity(16),
|
||||
stack_size: 16,
|
||||
file_sorting: FileSorting::ByName,
|
||||
group_dirs: None,
|
||||
opts: ExplorerOpts::empty(),
|
||||
fmt: Formatter::default(),
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileExplorer {
|
||||
/// ### pushd
|
||||
///
|
||||
/// push directory to stack
|
||||
pub fn pushd(&mut self, dir: &Path) {
|
||||
// Check if stack would overflow the size
|
||||
while self.dirstack.len() >= self.stack_size {
|
||||
self.dirstack.pop_front(); // Start cleaning events from back
|
||||
}
|
||||
// Eventually push front the new record
|
||||
self.dirstack.push_back(PathBuf::from(dir));
|
||||
}
|
||||
|
||||
/// ### popd
|
||||
///
|
||||
/// Pop directory from the stack and return the directory
|
||||
pub fn popd(&mut self) -> Option<PathBuf> {
|
||||
self.dirstack.pop_back()
|
||||
}
|
||||
|
||||
/// ### set_files
|
||||
///
|
||||
/// Set Explorer files
|
||||
/// This method will also sort entries based on current options
|
||||
/// Once all sorting have been performed, index is moved to first valid entry.
|
||||
pub fn set_files(&mut self, files: Vec<FsEntry>) {
|
||||
self.files = files;
|
||||
// Sort
|
||||
self.sort();
|
||||
}
|
||||
|
||||
/// ### del_entry
|
||||
///
|
||||
/// Delete file at provided index
|
||||
pub fn del_entry(&mut self, idx: usize) {
|
||||
if self.files.len() > idx {
|
||||
self.files.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
/// ### count
|
||||
///
|
||||
/// Return amount of files
|
||||
pub fn count(&self) -> usize {
|
||||
self.files.len()
|
||||
}
|
||||
*/
|
||||
|
||||
/// ### iter_files
|
||||
///
|
||||
/// Iterate over files
|
||||
/// Filters are applied based on current options (e.g. hidden files not returned)
|
||||
pub fn iter_files(&self) -> impl Iterator<Item = &FsEntry> + '_ {
|
||||
// Filter
|
||||
let opts: ExplorerOpts = self.opts;
|
||||
Box::new(self.files.iter().filter(move |x| {
|
||||
// If true, element IS NOT filtered
|
||||
let mut pass: bool = true;
|
||||
// If hidden files SHOULDN'T be shown, AND pass with not hidden
|
||||
if !opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES) {
|
||||
pass &= !x.is_hidden();
|
||||
}
|
||||
pass
|
||||
}))
|
||||
}
|
||||
|
||||
/// ### iter_files_all
|
||||
///
|
||||
/// Iterate all files; doesn't care about options
|
||||
pub fn iter_files_all(&self) -> impl Iterator<Item = &FsEntry> + '_ {
|
||||
Box::new(self.files.iter())
|
||||
}
|
||||
|
||||
/// ### get
|
||||
///
|
||||
/// Get file at relative index
|
||||
pub fn get(&self, idx: usize) -> Option<&FsEntry> {
|
||||
let opts: ExplorerOpts = self.opts;
|
||||
let filtered = self
|
||||
.files
|
||||
.iter()
|
||||
.filter(move |x| {
|
||||
// If true, element IS NOT filtered
|
||||
let mut pass: bool = true;
|
||||
// If hidden files SHOULDN'T be shown, AND pass with not hidden
|
||||
if !opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES) {
|
||||
pass &= !x.is_hidden();
|
||||
}
|
||||
pass
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
filtered.get(idx).copied()
|
||||
}
|
||||
|
||||
// Formatting
|
||||
|
||||
/// ### fmt_file
|
||||
///
|
||||
/// Format a file entry
|
||||
pub fn fmt_file(&self, entry: &FsEntry) -> String {
|
||||
self.fmt.fmt(entry)
|
||||
}
|
||||
|
||||
// Sorting
|
||||
|
||||
/// ### sort_by
|
||||
///
|
||||
/// Choose sorting method; then sort files
|
||||
pub fn sort_by(&mut self, sorting: FileSorting) {
|
||||
// If method HAS ACTUALLY CHANGED, sort (performance!)
|
||||
if self.file_sorting != sorting {
|
||||
self.file_sorting = sorting;
|
||||
self.sort();
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_file_sorting
|
||||
///
|
||||
/// Get current file sorting method
|
||||
pub fn get_file_sorting(&self) -> FileSorting {
|
||||
self.file_sorting
|
||||
}
|
||||
|
||||
/// ### group_dirs_by
|
||||
///
|
||||
/// Choose group dirs method; then sort files
|
||||
pub fn group_dirs_by(&mut self, group_dirs: Option<GroupDirs>) {
|
||||
// If method HAS ACTUALLY CHANGED, sort (performance!)
|
||||
if self.group_dirs != group_dirs {
|
||||
self.group_dirs = group_dirs;
|
||||
self.sort();
|
||||
}
|
||||
}
|
||||
|
||||
/// ### sort
|
||||
///
|
||||
/// Sort files based on Explorer options.
|
||||
fn sort(&mut self) {
|
||||
// Choose sorting method
|
||||
match &self.file_sorting {
|
||||
FileSorting::ByName => self.sort_files_by_name(),
|
||||
FileSorting::ByCreationTime => self.sort_files_by_creation_time(),
|
||||
FileSorting::ByModifyTime => self.sort_files_by_mtime(),
|
||||
FileSorting::BySize => self.sort_files_by_size(),
|
||||
}
|
||||
// Directories first (NOTE: MUST COME AFTER OTHER SORTING)
|
||||
// Group directories if necessary
|
||||
if let Some(group_dirs) = &self.group_dirs {
|
||||
match group_dirs {
|
||||
GroupDirs::First => self.sort_files_directories_first(),
|
||||
GroupDirs::Last => self.sort_files_directories_last(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### sort_files_by_name
|
||||
///
|
||||
/// Sort explorer files by their name. All names are converted to lowercase
|
||||
fn sort_files_by_name(&mut self) {
|
||||
self.files
|
||||
.sort_by_key(|x: &FsEntry| x.get_name().to_lowercase());
|
||||
}
|
||||
|
||||
/// ### sort_files_by_mtime
|
||||
///
|
||||
/// Sort files by mtime; the newest comes first
|
||||
fn sort_files_by_mtime(&mut self) {
|
||||
self.files.sort_by(|a: &FsEntry, b: &FsEntry| {
|
||||
b.get_last_change_time().cmp(&a.get_last_change_time())
|
||||
});
|
||||
}
|
||||
|
||||
/// ### sort_files_by_creation_time
|
||||
///
|
||||
/// Sort files by creation time; the newest comes first
|
||||
fn sort_files_by_creation_time(&mut self) {
|
||||
self.files
|
||||
.sort_by_key(|b: &FsEntry| Reverse(b.get_creation_time()));
|
||||
}
|
||||
|
||||
/// ### sort_files_by_size
|
||||
///
|
||||
/// Sort files by size
|
||||
fn sort_files_by_size(&mut self) {
|
||||
self.files.sort_by_key(|b: &FsEntry| Reverse(b.get_size()));
|
||||
}
|
||||
|
||||
/// ### sort_files_directories_first
|
||||
///
|
||||
/// Sort files; directories come first
|
||||
fn sort_files_directories_first(&mut self) {
|
||||
self.files.sort_by_key(|x: &FsEntry| x.is_file());
|
||||
}
|
||||
|
||||
/// ### sort_files_directories_last
|
||||
///
|
||||
/// Sort files; directories come last
|
||||
fn sort_files_directories_last(&mut self) {
|
||||
self.files.sort_by_key(|x: &FsEntry| x.is_dir());
|
||||
}
|
||||
|
||||
/// ### toggle_hidden_files
|
||||
///
|
||||
/// Enable/disable hidden files
|
||||
pub fn toggle_hidden_files(&mut self) {
|
||||
self.opts.toggle(ExplorerOpts::SHOW_HIDDEN_FILES);
|
||||
}
|
||||
}
|
||||
|
||||
// Traits
|
||||
|
||||
impl ToString for FileSorting {
|
||||
fn to_string(&self) -> String {
|
||||
String::from(match self {
|
||||
FileSorting::ByCreationTime => "by_creation_time",
|
||||
FileSorting::ByModifyTime => "by_mtime",
|
||||
FileSorting::ByName => "by_name",
|
||||
FileSorting::BySize => "by_size",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FileSorting {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"by_creation_time" => Ok(FileSorting::ByCreationTime),
|
||||
"by_mtime" => Ok(FileSorting::ByModifyTime),
|
||||
"by_name" => Ok(FileSorting::ByName),
|
||||
"by_size" => Ok(FileSorting::BySize),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for GroupDirs {
|
||||
fn to_string(&self) -> String {
|
||||
String::from(match self {
|
||||
GroupDirs::First => "first",
|
||||
GroupDirs::Last => "last",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for GroupDirs {
|
||||
type Err = ();
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"first" => Ok(GroupDirs::First),
|
||||
"last" => Ok(GroupDirs::Last),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::fs::{FsDirectory, FsFile};
|
||||
use crate::utils::fmt::fmt_time;
|
||||
|
||||
use std::thread::sleep;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_new() {
|
||||
let explorer: FileExplorer = FileExplorer::default();
|
||||
// Verify
|
||||
assert_eq!(explorer.dirstack.len(), 0);
|
||||
assert_eq!(explorer.files.len(), 0);
|
||||
assert_eq!(explorer.opts, ExplorerOpts::empty());
|
||||
assert_eq!(explorer.wrkdir, PathBuf::from("/"));
|
||||
assert_eq!(explorer.stack_size, 16);
|
||||
assert_eq!(explorer.group_dirs, None);
|
||||
assert_eq!(explorer.file_sorting, FileSorting::ByName);
|
||||
assert_eq!(explorer.get_file_sorting(), FileSorting::ByName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_stack() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
explorer.stack_size = 2;
|
||||
explorer.dirstack = VecDeque::with_capacity(2);
|
||||
// Push dir
|
||||
explorer.pushd(&Path::new("/tmp"));
|
||||
explorer.pushd(&Path::new("/home/omar"));
|
||||
// Pop
|
||||
assert_eq!(explorer.popd().unwrap(), PathBuf::from("/home/omar"));
|
||||
assert_eq!(explorer.dirstack.len(), 1);
|
||||
assert_eq!(explorer.popd().unwrap(), PathBuf::from("/tmp"));
|
||||
assert_eq!(explorer.dirstack.len(), 0);
|
||||
// Dirstack is empty now
|
||||
assert!(explorer.popd().is_none());
|
||||
// Exceed limit
|
||||
explorer.pushd(&Path::new("/tmp"));
|
||||
explorer.pushd(&Path::new("/home/omar"));
|
||||
explorer.pushd(&Path::new("/dev"));
|
||||
assert_eq!(explorer.dirstack.len(), 2);
|
||||
assert_eq!(*explorer.dirstack.get(1).unwrap(), PathBuf::from("/dev"));
|
||||
assert_eq!(
|
||||
*explorer.dirstack.get(0).unwrap(),
|
||||
PathBuf::from("/home/omar")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_files() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
// Don't show hidden files
|
||||
explorer.opts.remove(ExplorerOpts::SHOW_HIDDEN_FILES);
|
||||
// Create files
|
||||
explorer.set_files(vec![
|
||||
make_fs_entry("README.md", false),
|
||||
make_fs_entry("src/", true),
|
||||
make_fs_entry(".git/", true),
|
||||
make_fs_entry("CONTRIBUTING.md", false),
|
||||
make_fs_entry("codecov.yml", false),
|
||||
make_fs_entry(".gitignore", false),
|
||||
]);
|
||||
assert!(explorer.get(0).is_some());
|
||||
assert!(explorer.get(100).is_none());
|
||||
//assert_eq!(explorer.count(), 6);
|
||||
// Verify (files are sorted by name)
|
||||
assert_eq!(
|
||||
explorer.files.get(0).unwrap().get_name(),
|
||||
String::from(".git/")
|
||||
);
|
||||
// Iter files (all)
|
||||
assert_eq!(explorer.iter_files_all().count(), 6);
|
||||
// Iter files (hidden excluded) (.git, .gitignore are hidden)
|
||||
assert_eq!(explorer.iter_files().count(), 4);
|
||||
// Toggle hidden
|
||||
explorer.toggle_hidden_files();
|
||||
assert_eq!(explorer.iter_files().count(), 6); // All files are returned now
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_sort_by_name() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
// Create files (files are then sorted by name)
|
||||
explorer.set_files(vec![
|
||||
make_fs_entry("README.md", false),
|
||||
make_fs_entry("src/", true),
|
||||
make_fs_entry("CONTRIBUTING.md", false),
|
||||
make_fs_entry("CODE_OF_CONDUCT.md", false),
|
||||
make_fs_entry("CHANGELOG.md", false),
|
||||
make_fs_entry("LICENSE", false),
|
||||
make_fs_entry("Cargo.toml", false),
|
||||
make_fs_entry("Cargo.lock", false),
|
||||
make_fs_entry("codecov.yml", false),
|
||||
]);
|
||||
explorer.sort_by(FileSorting::ByName);
|
||||
// First entry should be "Cargo.lock"
|
||||
assert_eq!(explorer.files.get(0).unwrap().get_name(), "Cargo.lock");
|
||||
// Last should be "src/"
|
||||
assert_eq!(explorer.files.get(8).unwrap().get_name(), "src/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_sort_by_mtime() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
let entry1: FsEntry = make_fs_entry("README.md", false);
|
||||
// Wait 1 sec
|
||||
sleep(Duration::from_secs(1));
|
||||
let entry2: FsEntry = make_fs_entry("CODE_OF_CONDUCT.md", false);
|
||||
// Create files (files are then sorted by name)
|
||||
explorer.set_files(vec![entry1, entry2]);
|
||||
explorer.sort_by(FileSorting::ByModifyTime);
|
||||
// First entry should be "CODE_OF_CONDUCT.md"
|
||||
assert_eq!(
|
||||
explorer.files.get(0).unwrap().get_name(),
|
||||
"CODE_OF_CONDUCT.md"
|
||||
);
|
||||
// Last should be "src/"
|
||||
assert_eq!(explorer.files.get(1).unwrap().get_name(), "README.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_sort_by_creation_time() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
let entry1: FsEntry = make_fs_entry("README.md", false);
|
||||
// Wait 1 sec
|
||||
sleep(Duration::from_secs(1));
|
||||
let entry2: FsEntry = make_fs_entry("CODE_OF_CONDUCT.md", false);
|
||||
// Create files (files are then sorted by name)
|
||||
explorer.set_files(vec![entry1, entry2]);
|
||||
explorer.sort_by(FileSorting::ByCreationTime);
|
||||
// First entry should be "CODE_OF_CONDUCT.md"
|
||||
assert_eq!(
|
||||
explorer.files.get(0).unwrap().get_name(),
|
||||
"CODE_OF_CONDUCT.md"
|
||||
);
|
||||
// Last should be "src/"
|
||||
assert_eq!(explorer.files.get(1).unwrap().get_name(), "README.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_sort_by_size() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
// Create files (files are then sorted by name)
|
||||
explorer.set_files(vec![
|
||||
make_fs_entry_with_size("README.md", false, 1024),
|
||||
make_fs_entry("src/", true),
|
||||
make_fs_entry_with_size("CONTRIBUTING.md", false, 256),
|
||||
]);
|
||||
explorer.sort_by(FileSorting::BySize);
|
||||
// Directory has size 4096
|
||||
assert_eq!(explorer.files.get(0).unwrap().get_name(), "src/");
|
||||
assert_eq!(explorer.files.get(1).unwrap().get_name(), "README.md");
|
||||
assert_eq!(explorer.files.get(2).unwrap().get_name(), "CONTRIBUTING.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_sort_by_name_and_dirs_first() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
// Create files (files are then sorted by name)
|
||||
explorer.set_files(vec![
|
||||
make_fs_entry("README.md", false),
|
||||
make_fs_entry("src/", true),
|
||||
make_fs_entry("docs/", true),
|
||||
make_fs_entry("CONTRIBUTING.md", false),
|
||||
make_fs_entry("CODE_OF_CONDUCT.md", false),
|
||||
make_fs_entry("CHANGELOG.md", false),
|
||||
make_fs_entry("LICENSE", false),
|
||||
make_fs_entry("Cargo.toml", false),
|
||||
make_fs_entry("Cargo.lock", false),
|
||||
make_fs_entry("codecov.yml", false),
|
||||
]);
|
||||
explorer.sort_by(FileSorting::ByName);
|
||||
explorer.group_dirs_by(Some(GroupDirs::First));
|
||||
// First entry should be "docs"
|
||||
assert_eq!(explorer.files.get(0).unwrap().get_name(), "docs/");
|
||||
assert_eq!(explorer.files.get(1).unwrap().get_name(), "src/");
|
||||
// 3rd is file first for alphabetical order
|
||||
assert_eq!(explorer.files.get(2).unwrap().get_name(), "Cargo.lock");
|
||||
// Last should be "README.md" (last file for alphabetical ordening)
|
||||
assert_eq!(explorer.files.get(9).unwrap().get_name(), "README.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_sort_by_name_and_dirs_last() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
// Create files (files are then sorted by name)
|
||||
explorer.set_files(vec![
|
||||
make_fs_entry("README.md", false),
|
||||
make_fs_entry("src/", true),
|
||||
make_fs_entry("docs/", true),
|
||||
make_fs_entry("CONTRIBUTING.md", false),
|
||||
make_fs_entry("CODE_OF_CONDUCT.md", false),
|
||||
make_fs_entry("CHANGELOG.md", false),
|
||||
make_fs_entry("LICENSE", false),
|
||||
make_fs_entry("Cargo.toml", false),
|
||||
make_fs_entry("Cargo.lock", false),
|
||||
make_fs_entry("codecov.yml", false),
|
||||
]);
|
||||
explorer.sort_by(FileSorting::ByName);
|
||||
explorer.group_dirs_by(Some(GroupDirs::Last));
|
||||
// Last entry should be "src"
|
||||
assert_eq!(explorer.files.get(8).unwrap().get_name(), "docs/");
|
||||
assert_eq!(explorer.files.get(9).unwrap().get_name(), "src/");
|
||||
// first is file for alphabetical order
|
||||
assert_eq!(explorer.files.get(0).unwrap().get_name(), "Cargo.lock");
|
||||
// Last in files should be "README.md" (last file for alphabetical ordening)
|
||||
assert_eq!(explorer.files.get(7).unwrap().get_name(), "README.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_fmt() {
|
||||
let explorer: FileExplorer = FileExplorer::default();
|
||||
// Create fs entry
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t,
|
||||
last_access_time: t,
|
||||
creation_time: t,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
assert_eq!(
|
||||
explorer.fmt_file(&entry),
|
||||
format!(
|
||||
"bar.txt -rw-r--r-- root 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
explorer.fmt_file(&entry),
|
||||
format!(
|
||||
"bar.txt -rw-r--r-- 0 8.2 KB {}",
|
||||
fmt_time(t, "%b %d %Y %H:%M")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_to_string_from_str_traits() {
|
||||
// File Sorting
|
||||
assert_eq!(FileSorting::ByCreationTime.to_string(), "by_creation_time");
|
||||
assert_eq!(FileSorting::ByModifyTime.to_string(), "by_mtime");
|
||||
assert_eq!(FileSorting::ByName.to_string(), "by_name");
|
||||
assert_eq!(FileSorting::BySize.to_string(), "by_size");
|
||||
assert_eq!(
|
||||
FileSorting::from_str("by_creation_time").ok().unwrap(),
|
||||
FileSorting::ByCreationTime
|
||||
);
|
||||
assert_eq!(
|
||||
FileSorting::from_str("by_mtime").ok().unwrap(),
|
||||
FileSorting::ByModifyTime
|
||||
);
|
||||
assert_eq!(
|
||||
FileSorting::from_str("by_name").ok().unwrap(),
|
||||
FileSorting::ByName
|
||||
);
|
||||
assert_eq!(
|
||||
FileSorting::from_str("by_size").ok().unwrap(),
|
||||
FileSorting::BySize
|
||||
);
|
||||
assert!(FileSorting::from_str("omar").is_err());
|
||||
// Group dirs
|
||||
assert_eq!(GroupDirs::First.to_string(), "first");
|
||||
assert_eq!(GroupDirs::Last.to_string(), "last");
|
||||
assert_eq!(GroupDirs::from_str("first").ok().unwrap(), GroupDirs::First);
|
||||
assert_eq!(GroupDirs::from_str("last").ok().unwrap(), GroupDirs::Last);
|
||||
assert!(GroupDirs::from_str("omar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_del_entry() {
|
||||
let mut explorer: FileExplorer = FileExplorer::default();
|
||||
// Create files (files are then sorted by name)
|
||||
explorer.set_files(vec![
|
||||
make_fs_entry("CONTRIBUTING.md", false),
|
||||
make_fs_entry("docs/", true),
|
||||
make_fs_entry("src/", true),
|
||||
make_fs_entry("README.md", false),
|
||||
]);
|
||||
explorer.del_entry(0);
|
||||
assert_eq!(explorer.files.len(), 3);
|
||||
assert_eq!(explorer.files[0].get_name(), "docs/");
|
||||
explorer.del_entry(5);
|
||||
assert_eq!(explorer.files.len(), 3);
|
||||
}
|
||||
|
||||
fn make_fs_entry(name: &str, is_dir: bool) -> FsEntry {
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
match is_dir {
|
||||
false => FsEntry::File(FsFile {
|
||||
name: name.to_string(),
|
||||
abs_path: PathBuf::from(name),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: 64,
|
||||
ftype: None, // File type
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
}),
|
||||
true => FsEntry::Directory(FsDirectory {
|
||||
name: name.to_string(),
|
||||
abs_path: PathBuf::from(name),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_fs_entry_with_size(name: &str, is_dir: bool, size: usize) -> FsEntry {
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
match is_dir {
|
||||
false => FsEntry::File(FsFile {
|
||||
name: name.to_string(),
|
||||
abs_path: PathBuf::from(name),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: size,
|
||||
ftype: None, // File type
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
}),
|
||||
true => FsEntry::Directory(FsDirectory {
|
||||
name: name.to_string(),
|
||||
abs_path: PathBuf::from(name),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
592
src/fs/mod.rs
@@ -2,38 +2,34 @@
|
||||
//!
|
||||
//! `fs` is the module which provides file system entities
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
extern crate bytesize;
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
extern crate users;
|
||||
|
||||
use crate::utils::{fmt_pex, time_to_str};
|
||||
|
||||
use bytesize::ByteSize;
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Mod
|
||||
pub mod explorer;
|
||||
// Ext
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
use users::get_user_by_uid;
|
||||
|
||||
/// ## FsEntry
|
||||
///
|
||||
@@ -57,7 +53,7 @@ pub struct FsDirectory {
|
||||
pub last_access_time: SystemTime,
|
||||
pub creation_time: SystemTime,
|
||||
pub readonly: bool,
|
||||
pub symlink: Option<PathBuf>, // UNIX only
|
||||
pub symlink: Option<Box<FsEntry>>, // UNIX only
|
||||
pub user: Option<u32>, // UNIX only
|
||||
pub group: Option<u32>, // UNIX only
|
||||
pub unix_pex: Option<(u8, u8, u8)>, // UNIX only
|
||||
@@ -77,205 +73,365 @@ pub struct FsFile {
|
||||
pub size: usize,
|
||||
pub ftype: Option<String>, // File type
|
||||
pub readonly: bool,
|
||||
pub symlink: Option<PathBuf>, // UNIX only
|
||||
pub symlink: Option<Box<FsEntry>>, // UNIX only
|
||||
pub user: Option<u32>, // UNIX only
|
||||
pub group: Option<u32>, // UNIX only
|
||||
pub unix_pex: Option<(u8, u8, u8)>, // UNIX only
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FsEntry {
|
||||
/// ### fmt_ls
|
||||
impl FsEntry {
|
||||
/// ### get_abs_path
|
||||
///
|
||||
/// Format File Entry as `ls` does
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
/// Get absolute path from `FsEntry`
|
||||
pub fn get_abs_path(&self) -> PathBuf {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => {
|
||||
// Create mode string
|
||||
let mut mode: String = String::with_capacity(10);
|
||||
let file_type: char = match dir.symlink {
|
||||
Some(_) => 'l',
|
||||
None => 'd',
|
||||
};
|
||||
mode.push(file_type);
|
||||
match dir.unix_pex {
|
||||
None => mode.push_str("?????????"),
|
||||
Some((owner, group, others)) => {
|
||||
mode.push_str(fmt_pex(owner, group, others).as_str())
|
||||
}
|
||||
}
|
||||
// Get username
|
||||
let username: String = match dir.user {
|
||||
Some(uid) => match get_user_by_uid(uid) {
|
||||
Some(user) => user.name().to_string_lossy().to_string(),
|
||||
None => uid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
// Get group
|
||||
/*
|
||||
let group: String = match dir.group {
|
||||
Some(gid) => match get_group_by_gid(gid) {
|
||||
Some(group) => group.name().to_string_lossy().to_string(),
|
||||
None => gid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
*/
|
||||
// Get byte size
|
||||
let size: String = String::from("4096");
|
||||
// Get date
|
||||
let datetime: String = time_to_str(dir.last_change_time, "%b %d %Y %H:%M");
|
||||
// Set file name (or elide if too long)
|
||||
let dir_name: String = match dir.name.len() >= 24 {
|
||||
false => dir.name.clone(),
|
||||
true => format!("{}...", &dir.name.as_str()[0..20]),
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"{:24}\t{:12}\t{:12}\t{:9}\t{:17}",
|
||||
dir_name, mode, username, size, datetime
|
||||
)
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
// Create mode string
|
||||
let mut mode: String = String::with_capacity(10);
|
||||
let file_type: char = match file.symlink {
|
||||
Some(_) => 'l',
|
||||
None => '-',
|
||||
};
|
||||
mode.push(file_type);
|
||||
match file.unix_pex {
|
||||
None => mode.push_str("?????????"),
|
||||
Some((owner, group, others)) => {
|
||||
mode.push_str(fmt_pex(owner, group, others).as_str())
|
||||
}
|
||||
}
|
||||
// Get username
|
||||
let username: String = match file.user {
|
||||
Some(uid) => match get_user_by_uid(uid) {
|
||||
Some(user) => user.name().to_string_lossy().to_string(),
|
||||
None => uid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
// Get group
|
||||
/*
|
||||
let group: String = match file.group {
|
||||
Some(gid) => match get_group_by_gid(gid) {
|
||||
Some(group) => group.name().to_string_lossy().to_string(),
|
||||
None => gid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
*/
|
||||
// Get byte size
|
||||
let size: ByteSize = ByteSize(file.size as u64);
|
||||
// Get date
|
||||
let datetime: String = time_to_str(file.last_change_time, "%b %d %Y %H:%M");
|
||||
// Set file name (or elide if too long)
|
||||
let file_name: String = match file.name.len() >= 24 {
|
||||
false => file.name.clone(),
|
||||
true => format!("{}...", &file.name.as_str()[0..20]),
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"{:24}\t{:12}\t{:12}\t{:9}\t{:17}",
|
||||
file_name, mode, username, size, datetime
|
||||
)
|
||||
}
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### fmt_ls
|
||||
/// ### get_name
|
||||
///
|
||||
/// Format File Entry as `ls` does
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
/// Get file name from `FsEntry`
|
||||
pub fn get_name(&self) -> &'_ str {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => {
|
||||
// Create mode string
|
||||
let mut mode: String = String::with_capacity(10);
|
||||
let file_type: char = match dir.symlink {
|
||||
Some(_) => 'l',
|
||||
None => 'd',
|
||||
};
|
||||
mode.push(file_type);
|
||||
match dir.unix_pex {
|
||||
None => mode.push_str("?????????"),
|
||||
Some((owner, group, others)) => {
|
||||
mode.push_str(fmt_pex(owner, group, others).as_str())
|
||||
}
|
||||
}
|
||||
// Get username
|
||||
let username: String = match dir.user {
|
||||
Some(uid) => uid.to_string(),
|
||||
None => String::from("0"),
|
||||
};
|
||||
// Get group
|
||||
/*
|
||||
let group: String = match dir.group {
|
||||
Some(gid) => gid.to_string(),
|
||||
None => String::from("0"),
|
||||
};
|
||||
*/
|
||||
// Get byte size
|
||||
let size: String = String::from("4096");
|
||||
// Get date
|
||||
let datetime: String = time_to_str(dir.last_change_time, "%b %d %Y %H:%M");
|
||||
// Set file name (or elide if too long)
|
||||
let dir_name: String = match dir.name.len() >= 24 {
|
||||
false => dir.name.clone(),
|
||||
true => format!("{}...", &dir.name.as_str()[0..20]),
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"{:24}\t{:12}\t{:12}\t{:9}\t{:17}",
|
||||
dir_name, mode, username, size, datetime
|
||||
)
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
// Create mode string
|
||||
let mut mode: String = String::with_capacity(10);
|
||||
let file_type: char = match file.symlink {
|
||||
Some(_) => 'l',
|
||||
None => '-',
|
||||
};
|
||||
mode.push(file_type);
|
||||
match file.unix_pex {
|
||||
None => mode.push_str("?????????"),
|
||||
Some((owner, group, others)) => {
|
||||
mode.push_str(fmt_pex(owner, group, others).as_str())
|
||||
}
|
||||
}
|
||||
// Get username
|
||||
let username: String = match file.user {
|
||||
Some(uid) => uid.to_string(),
|
||||
None => String::from("0"),
|
||||
};
|
||||
// Get group
|
||||
/*
|
||||
let group: String = match file.group {
|
||||
Some(gid) => gid.to_string(),
|
||||
None => String::from("0"),
|
||||
};
|
||||
*/
|
||||
// Get byte size
|
||||
let size: ByteSize = ByteSize(file.size as u64);
|
||||
// Get date
|
||||
let datetime: String = time_to_str(file.last_change_time, "%b %d %Y %H:%M");
|
||||
// Set file name (or elide if too long)
|
||||
let file_name: String = match file.name.len() >= 24 {
|
||||
false => file.name.clone(),
|
||||
true => format!("{}...", &file.name.as_str()[0..20]),
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"{:24}\t{:12}\t{:12}\t{:9}\t{:17}",
|
||||
file_name, mode, username, size, datetime
|
||||
)
|
||||
}
|
||||
FsEntry::Directory(dir) => dir.name.as_ref(),
|
||||
FsEntry::File(file) => file.name.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_last_change_time
|
||||
///
|
||||
/// Get last change time from `FsEntry`
|
||||
pub fn get_last_change_time(&self) -> SystemTime {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => dir.last_change_time,
|
||||
FsEntry::File(file) => file.last_change_time,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_last_access_time
|
||||
///
|
||||
/// Get access time from `FsEntry`
|
||||
pub fn get_last_access_time(&self) -> SystemTime {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => dir.last_access_time,
|
||||
FsEntry::File(file) => file.last_access_time,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_creation_time
|
||||
///
|
||||
/// Get creation time from `FsEntry`
|
||||
pub fn get_creation_time(&self) -> SystemTime {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => dir.creation_time,
|
||||
FsEntry::File(file) => file.creation_time,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_size
|
||||
///
|
||||
/// Get size from `FsEntry`. For directories is always 4096
|
||||
pub fn get_size(&self) -> usize {
|
||||
match self {
|
||||
FsEntry::Directory(_) => 4096,
|
||||
FsEntry::File(file) => file.size,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_ftype
|
||||
///
|
||||
/// Get file type from `FsEntry`. For directories is always None
|
||||
pub fn get_ftype(&self) -> Option<String> {
|
||||
match self {
|
||||
FsEntry::Directory(_) => None,
|
||||
FsEntry::File(file) => file.ftype.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_user
|
||||
///
|
||||
/// Get uid from `FsEntry`
|
||||
pub fn get_user(&self) -> Option<u32> {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => dir.user,
|
||||
FsEntry::File(file) => file.user,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_group
|
||||
///
|
||||
/// Get gid from `FsEntry`
|
||||
pub fn get_group(&self) -> Option<u32> {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => dir.group,
|
||||
FsEntry::File(file) => file.group,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_unix_pex
|
||||
///
|
||||
/// Get unix pex from `FsEntry`
|
||||
pub fn get_unix_pex(&self) -> Option<(u8, u8, u8)> {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => dir.unix_pex,
|
||||
FsEntry::File(file) => file.unix_pex,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### is_symlink
|
||||
///
|
||||
/// Returns whether the `FsEntry` is a symlink
|
||||
pub fn is_symlink(&self) -> bool {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => dir.symlink.is_some(),
|
||||
FsEntry::File(file) => file.symlink.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### is_dir
|
||||
///
|
||||
/// Returns whether a FsEntry is a directory
|
||||
pub fn is_dir(&self) -> bool {
|
||||
matches!(self, FsEntry::Directory(_))
|
||||
}
|
||||
|
||||
/// ### is_file
|
||||
///
|
||||
/// Returns whether a FsEntry is a File
|
||||
pub fn is_file(&self) -> bool {
|
||||
matches!(self, FsEntry::File(_))
|
||||
}
|
||||
|
||||
/// ### is_hidden
|
||||
///
|
||||
/// Returns whether FsEntry is hidden
|
||||
pub fn is_hidden(&self) -> bool {
|
||||
self.get_name().starts_with('.')
|
||||
}
|
||||
|
||||
/// ### get_realfile
|
||||
///
|
||||
/// Return the real file pointed by a `FsEntry`
|
||||
pub fn get_realfile(&self) -> FsEntry {
|
||||
match self {
|
||||
FsEntry::Directory(dir) => match &dir.symlink {
|
||||
Some(symlink) => symlink.get_realfile(),
|
||||
None => self.clone(),
|
||||
},
|
||||
FsEntry::File(file) => match &file.symlink {
|
||||
Some(symlink) => symlink.get_realfile(),
|
||||
None => self.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fs_fsentry_dir() {
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
let entry: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("foo"),
|
||||
abs_path: PathBuf::from("/foo"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
});
|
||||
assert_eq!(entry.get_abs_path(), PathBuf::from("/foo"));
|
||||
assert_eq!(entry.get_name(), String::from("foo"));
|
||||
assert_eq!(entry.get_last_access_time(), t_now);
|
||||
assert_eq!(entry.get_last_change_time(), t_now);
|
||||
assert_eq!(entry.get_creation_time(), t_now);
|
||||
assert_eq!(entry.get_size(), 4096);
|
||||
assert_eq!(entry.get_ftype(), None);
|
||||
assert_eq!(entry.get_user(), Some(0));
|
||||
assert_eq!(entry.get_group(), Some(0));
|
||||
assert_eq!(entry.is_symlink(), false);
|
||||
assert_eq!(entry.is_dir(), true);
|
||||
assert_eq!(entry.is_file(), false);
|
||||
assert_eq!(entry.get_unix_pex(), Some((7, 5, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_fsentry_file() {
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
assert_eq!(entry.get_abs_path(), PathBuf::from("/bar.txt"));
|
||||
assert_eq!(entry.get_name(), String::from("bar.txt"));
|
||||
assert_eq!(entry.get_last_access_time(), t_now);
|
||||
assert_eq!(entry.get_last_change_time(), t_now);
|
||||
assert_eq!(entry.get_creation_time(), t_now);
|
||||
assert_eq!(entry.get_size(), 8192);
|
||||
assert_eq!(entry.get_ftype(), Some(String::from("txt")));
|
||||
assert_eq!(entry.get_user(), Some(0));
|
||||
assert_eq!(entry.get_group(), Some(0));
|
||||
assert_eq!(entry.get_unix_pex(), Some((6, 4, 4)));
|
||||
assert_eq!(entry.is_symlink(), false);
|
||||
assert_eq!(entry.is_dir(), false);
|
||||
assert_eq!(entry.is_file(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_fsentry_hidden_files() {
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
assert_eq!(entry.is_hidden(), false);
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from(".gitignore"),
|
||||
abs_path: PathBuf::from("/.gitignore"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
assert_eq!(entry.is_hidden(), true);
|
||||
let entry: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from(".git"),
|
||||
abs_path: PathBuf::from("/.git"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
});
|
||||
assert_eq!(entry.is_hidden(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_fsentry_realfile_none() {
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
// With file...
|
||||
let entry: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("bar.txt"),
|
||||
abs_path: PathBuf::from("/bar.txt"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: 8192,
|
||||
readonly: false,
|
||||
ftype: Some(String::from("txt")),
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((6, 4, 4)), // UNIX only
|
||||
});
|
||||
// Symlink is None...
|
||||
assert_eq!(
|
||||
entry.get_realfile().get_abs_path(),
|
||||
PathBuf::from("/bar.txt")
|
||||
);
|
||||
// With directory...
|
||||
let entry: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("foo"),
|
||||
abs_path: PathBuf::from("/foo"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((7, 5, 5)), // UNIX only
|
||||
});
|
||||
assert_eq!(entry.get_realfile().get_abs_path(), PathBuf::from("/foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_fsentry_realfile_some() {
|
||||
let t_now: SystemTime = SystemTime::now();
|
||||
// Prepare entries
|
||||
// root -> child -> target
|
||||
let entry_target: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("projects"),
|
||||
abs_path: PathBuf::from("/home/cvisintin/projects"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: None, // UNIX only
|
||||
user: Some(0), // UNIX only
|
||||
group: Some(0), // UNIX only
|
||||
unix_pex: Some((7, 7, 7)), // UNIX only
|
||||
});
|
||||
let entry_child: FsEntry = FsEntry::Directory(FsDirectory {
|
||||
name: String::from("projects"),
|
||||
abs_path: PathBuf::from("/develop/projects"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
readonly: false,
|
||||
symlink: Some(Box::new(entry_target)),
|
||||
user: Some(0),
|
||||
group: Some(0),
|
||||
unix_pex: Some((7, 7, 7)),
|
||||
});
|
||||
let entry_root: FsEntry = FsEntry::File(FsFile {
|
||||
name: String::from("projects"),
|
||||
abs_path: PathBuf::from("/projects"),
|
||||
last_change_time: t_now,
|
||||
last_access_time: t_now,
|
||||
creation_time: t_now,
|
||||
size: 8,
|
||||
readonly: false,
|
||||
ftype: None,
|
||||
symlink: Some(Box::new(entry_child)),
|
||||
user: Some(0),
|
||||
group: Some(0),
|
||||
unix_pex: Some((7, 7, 7)),
|
||||
});
|
||||
assert_eq!(entry_root.is_symlink(), true);
|
||||
// get real file
|
||||
let real_file: FsEntry = entry_root.get_realfile();
|
||||
// real file must be projects in /home/cvisintin
|
||||
assert_eq!(
|
||||
real_file.get_abs_path(),
|
||||
PathBuf::from("/home/cvisintin/projects")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
843
src/host/mod.rs
53
src/lib.rs
@@ -1,29 +1,40 @@
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#[macro_use] extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate bitflags;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate magic_crypt;
|
||||
|
||||
pub mod activity_manager;
|
||||
pub mod bookmarks;
|
||||
pub mod config;
|
||||
pub mod filetransfer;
|
||||
pub mod fs;
|
||||
pub mod host;
|
||||
pub mod system;
|
||||
pub mod ui;
|
||||
pub mod utils;
|
||||
|
||||
103
src/main.rs
@@ -1,30 +1,38 @@
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const TERMSCP_VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
||||
const TERMSCP_AUTHORS: &'static str = env!("CARGO_PKG_AUTHORS");
|
||||
const TERMSCP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const TERMSCP_AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
|
||||
|
||||
// Crates
|
||||
extern crate getopts;
|
||||
#[macro_use] extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate bitflags;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate magic_crypt;
|
||||
extern crate rpassword;
|
||||
|
||||
// External libs
|
||||
@@ -35,9 +43,12 @@ use std::time::Duration;
|
||||
|
||||
// Include
|
||||
mod activity_manager;
|
||||
mod bookmarks;
|
||||
mod config;
|
||||
mod filetransfer;
|
||||
mod fs;
|
||||
mod host;
|
||||
mod system;
|
||||
mod ui;
|
||||
mod utils;
|
||||
|
||||
@@ -50,9 +61,11 @@ use filetransfer::FileTransferProtocol;
|
||||
/// Print usage
|
||||
|
||||
fn print_usage(opts: Options) {
|
||||
let brief = format!("Usage: termscp [options]... [protocol://user@address:port]");
|
||||
let brief = String::from(
|
||||
"Usage: termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]",
|
||||
);
|
||||
print!("{}", opts.usage(&brief));
|
||||
println!("\nPlease, report issues to <https://github.com/ChristianVisintin/TermSCP>");
|
||||
println!("\nPlease, report issues to <https://github.com/veeso/termscp>");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -62,6 +75,7 @@ fn main() {
|
||||
let mut port: u16 = 22; // Default port
|
||||
let mut username: Option<String> = None; // Default username
|
||||
let mut password: Option<String> = None; // Default password
|
||||
let mut remote_wrkdir: Option<PathBuf> = None;
|
||||
let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; // Default protocol
|
||||
let mut ticks: Duration = Duration::from_millis(10);
|
||||
//Process options
|
||||
@@ -97,7 +111,7 @@ fn main() {
|
||||
}
|
||||
// Match password
|
||||
if let Some(passwd) = matches.opt_str("P") {
|
||||
password = Some(String::from(passwd));
|
||||
password = Some(passwd);
|
||||
}
|
||||
// Match ticks
|
||||
if let Some(val) = matches.opt_str("T") {
|
||||
@@ -111,16 +125,18 @@ fn main() {
|
||||
}
|
||||
}
|
||||
// Check free args
|
||||
let extra_args: Vec<String> = matches.free.clone();
|
||||
let extra_args: Vec<String> = matches.free;
|
||||
// Remote argument
|
||||
if let Some(remote) = extra_args.get(0) {
|
||||
// Parse address
|
||||
match utils::parse_remote_opt(remote) {
|
||||
Ok((addr, portn, proto, user)) => {
|
||||
match utils::parser::parse_remote_opt(remote) {
|
||||
Ok(host_opts) => {
|
||||
// Set params
|
||||
address = Some(addr);
|
||||
port = portn;
|
||||
protocol = proto;
|
||||
username = user;
|
||||
address = Some(host_opts.hostname);
|
||||
port = host_opts.port;
|
||||
protocol = host_opts.protocol;
|
||||
username = host_opts.username;
|
||||
remote_wrkdir = host_opts.wrkdir;
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Bad address option: {}", err);
|
||||
@@ -129,6 +145,15 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Local directory
|
||||
if let Some(localdir) = extra_args.get(1) {
|
||||
// Change working directory if local dir is set
|
||||
let localdir: PathBuf = PathBuf::from(localdir);
|
||||
if let Err(err) = env::set_current_dir(localdir.as_path()) {
|
||||
eprintln!("Bad working directory argument: {}", err);
|
||||
std::process::exit(255);
|
||||
}
|
||||
}
|
||||
// Get working directory
|
||||
let wrkdir: PathBuf = match env::current_dir() {
|
||||
Ok(dir) => dir,
|
||||
@@ -141,10 +166,10 @@ fn main() {
|
||||
// Ask password if unspecified
|
||||
password = match rpassword::read_password_from_tty(Some("Password: ")) {
|
||||
Ok(p) => {
|
||||
if p.len() > 0 {
|
||||
Some(p)
|
||||
} else {
|
||||
if p.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(p)
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -159,14 +184,14 @@ fn main() {
|
||||
// Create activity manager (and context too)
|
||||
let mut manager: ActivityManager = match ActivityManager::new(&wrkdir, ticks) {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
eprintln!("Invalid directory '{}'", wrkdir.display());
|
||||
Err(err) => {
|
||||
eprintln!("Could not start activity manager: {}", err);
|
||||
std::process::exit(255);
|
||||
}
|
||||
};
|
||||
// Set file transfer params if set
|
||||
if let Some(address) = address {
|
||||
manager.set_filetransfer_params(address, port, protocol, username, password);
|
||||
manager.set_filetransfer_params(address, port, protocol, username, password, remote_wrkdir);
|
||||
}
|
||||
// Run
|
||||
manager.run(start_activity);
|
||||
|
||||
667
src/system/bookmarks_client.rs
Normal file
@@ -0,0 +1,667 @@
|
||||
//! ## BookmarksClient
|
||||
//!
|
||||
//! `bookmarks_client` is the module which provides an API between the Bookmarks module and the system
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Deps
|
||||
extern crate whoami;
|
||||
// Crate
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use super::keys::keyringstorage::KeyringStorage;
|
||||
use super::keys::{filestorage::FileStorage, KeyStorage, KeyStorageError};
|
||||
// Local
|
||||
use crate::bookmarks::serializer::BookmarkSerializer;
|
||||
use crate::bookmarks::{Bookmark, SerializerError, SerializerErrorKind, UserHosts};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::utils::crypto;
|
||||
use crate::utils::fmt::fmt_time;
|
||||
use crate::utils::random::random_alphanumeric_with_len;
|
||||
// Ext
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::string::ToString;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// ## BookmarksClient
|
||||
///
|
||||
/// BookmarksClient provides a layer between the host system and the bookmarks module
|
||||
pub struct BookmarksClient {
|
||||
hosts: UserHosts,
|
||||
bookmarks_file: PathBuf,
|
||||
key: String,
|
||||
recents_size: usize,
|
||||
}
|
||||
|
||||
impl BookmarksClient {
|
||||
/// ### BookmarksClient
|
||||
///
|
||||
/// Instantiates a new BookmarksClient
|
||||
/// Bookmarks file path must be provided
|
||||
/// Storage path for file provider must be provided
|
||||
pub fn new(
|
||||
bookmarks_file: &Path,
|
||||
storage_path: &Path,
|
||||
recents_size: usize,
|
||||
) -> Result<BookmarksClient, SerializerError> {
|
||||
// Create default hosts
|
||||
let default_hosts: UserHosts = Default::default();
|
||||
// Make a key storage (windows / macos)
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
|
||||
let username: String = whoami::username();
|
||||
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
|
||||
// Check if keyring storage is supported
|
||||
#[cfg(not(test))]
|
||||
let app_name: &str = "termscp";
|
||||
#[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"),
|
||||
}
|
||||
};
|
||||
// Make a key storage (linux / unix)
|
||||
#[cfg(any(target_os = "linux", target_os = "unix"))]
|
||||
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
|
||||
#[cfg(not(test))]
|
||||
let app_name: &str = "bookmarks";
|
||||
#[cfg(test)] // NOTE: when running test, add -test
|
||||
let app_name: &str = "bookmarks-test";
|
||||
(Box::new(FileStorage::new(storage_path)), app_name)
|
||||
};
|
||||
// Load key
|
||||
let key: String = match key_storage.get_key(service_id) {
|
||||
Ok(k) => 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();
|
||||
if let Err(e) = key_storage.set_key(service_id, key.as_str()) {
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
format!("Could not write key to storage: {}", e),
|
||||
));
|
||||
}
|
||||
// Return key
|
||||
key
|
||||
}
|
||||
_ => {
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
format!("Could not get key from storage: {}", e),
|
||||
))
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut client: BookmarksClient = BookmarksClient {
|
||||
hosts: default_hosts,
|
||||
bookmarks_file: PathBuf::from(bookmarks_file),
|
||||
key,
|
||||
recents_size,
|
||||
};
|
||||
// If bookmark file doesn't exist, initialize it
|
||||
if !bookmarks_file.exists() {
|
||||
if let Err(err) = client.write_bookmarks() {
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
// Load bookmarks from file
|
||||
if let Err(err) = client.read_bookmarks() {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
// Load key
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// ### iter_bookmarks
|
||||
///
|
||||
/// Iterate over bookmarks keys
|
||||
pub fn iter_bookmarks(&self) -> impl Iterator<Item = &String> + '_ {
|
||||
Box::new(self.hosts.bookmarks.keys())
|
||||
}
|
||||
|
||||
/// ### get_bookmark
|
||||
///
|
||||
/// Get bookmark associated to key
|
||||
pub fn get_bookmark(
|
||||
&self,
|
||||
key: &str,
|
||||
) -> Option<(String, u16, FileTransferProtocol, String, Option<String>)> {
|
||||
let entry: &Bookmark = self.hosts.bookmarks.get(key)?;
|
||||
Some((
|
||||
entry.address.clone(),
|
||||
entry.port,
|
||||
match FileTransferProtocol::from_str(entry.protocol.as_str()) {
|
||||
Ok(proto) => proto,
|
||||
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,
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// ### add_recent
|
||||
///
|
||||
/// Add a new recent to bookmarks
|
||||
pub fn add_bookmark(
|
||||
&mut self,
|
||||
name: String,
|
||||
addr: String,
|
||||
port: u16,
|
||||
protocol: FileTransferProtocol,
|
||||
username: String,
|
||||
password: Option<String>,
|
||||
) {
|
||||
if name.is_empty() {
|
||||
panic!("Bookmark name can't be empty");
|
||||
}
|
||||
// Make bookmark
|
||||
let host: Bookmark = self.make_bookmark(addr, port, protocol, username, password);
|
||||
self.hosts.bookmarks.insert(name, host);
|
||||
}
|
||||
|
||||
/// ### del_bookmark
|
||||
///
|
||||
/// Delete entry from bookmarks
|
||||
pub fn del_bookmark(&mut self, name: &str) {
|
||||
let _ = self.hosts.bookmarks.remove(name);
|
||||
}
|
||||
/// ### iter_recents
|
||||
///
|
||||
/// Iterate over recents keys
|
||||
pub fn iter_recents(&self) -> impl Iterator<Item = &String> + '_ {
|
||||
Box::new(self.hosts.recents.keys())
|
||||
}
|
||||
|
||||
/// ### get_recent
|
||||
///
|
||||
/// 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
|
||||
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
|
||||
},
|
||||
entry.username.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// ### add_recent
|
||||
///
|
||||
/// Add a new recent to bookmarks
|
||||
pub fn add_recent(
|
||||
&mut self,
|
||||
addr: String,
|
||||
port: u16,
|
||||
protocol: FileTransferProtocol,
|
||||
username: String,
|
||||
) {
|
||||
// Make bookmark
|
||||
let host: Bookmark = self.make_bookmark(addr, port, protocol, username, None);
|
||||
// Check if duplicated
|
||||
for recent_host in self.hosts.recents.values() {
|
||||
if *recent_host == host {
|
||||
// Don't save duplicates
|
||||
return;
|
||||
}
|
||||
}
|
||||
// If hosts size is bigger than self.recents_size; pop last
|
||||
if self.hosts.recents.len() >= self.recents_size {
|
||||
// Get keys
|
||||
let mut keys: Vec<String> = Vec::with_capacity(self.hosts.recents.len());
|
||||
for key in self.hosts.recents.keys() {
|
||||
keys.push(key.clone());
|
||||
}
|
||||
// Sort keys; NOTE: most recent is the last element
|
||||
keys.sort();
|
||||
// Delete keys starting from the last one
|
||||
for key in keys.iter() {
|
||||
let _ = self.hosts.recents.remove(key);
|
||||
// If length is < self.recents_size; break
|
||||
if self.hosts.recents.len() < self.recents_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let name: String = fmt_time(SystemTime::now(), "ISO%Y%m%dT%H%M%S");
|
||||
self.hosts.recents.insert(name, host);
|
||||
}
|
||||
|
||||
/// ### del_recent
|
||||
///
|
||||
/// Delete entry from recents
|
||||
pub fn del_recent(&mut self, name: &str) {
|
||||
let _ = self.hosts.recents.remove(name);
|
||||
}
|
||||
|
||||
/// ### write_bookmarks
|
||||
///
|
||||
/// Write bookmarks to file
|
||||
pub fn write_bookmarks(&self) -> Result<(), SerializerError> {
|
||||
// Open file
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(self.bookmarks_file.as_path())
|
||||
{
|
||||
Ok(writer) => {
|
||||
let serializer: BookmarkSerializer = BookmarkSerializer {};
|
||||
serializer.serialize(Box::new(writer), &self.hosts)
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### read_bookmarks
|
||||
///
|
||||
/// Read bookmarks from file
|
||||
fn read_bookmarks(&mut self) -> Result<(), SerializerError> {
|
||||
// Open bookmarks file for read
|
||||
match OpenOptions::new()
|
||||
.read(true)
|
||||
.open(self.bookmarks_file.as_path())
|
||||
{
|
||||
Ok(reader) => {
|
||||
// Deserialize
|
||||
let deserializer: BookmarkSerializer = BookmarkSerializer {};
|
||||
match deserializer.deserialize(Box::new(reader)) {
|
||||
Ok(hosts) => {
|
||||
self.hosts = hosts;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### generate_key
|
||||
///
|
||||
/// Generate a new AES key
|
||||
fn generate_key() -> String {
|
||||
// Generate 256 bytes (2048 bits) key
|
||||
random_alphanumeric_with_len(256)
|
||||
}
|
||||
|
||||
/// ### make_bookmark
|
||||
///
|
||||
/// Make bookmark from credentials
|
||||
fn make_bookmark(
|
||||
&self,
|
||||
addr: String,
|
||||
port: u16,
|
||||
protocol: FileTransferProtocol,
|
||||
username: String,
|
||||
password: Option<String>,
|
||||
) -> Bookmark {
|
||||
Bookmark {
|
||||
address: addr,
|
||||
port,
|
||||
username,
|
||||
protocol: protocol.to_string(),
|
||||
password: password.map(|p| self.encrypt_str(p.as_str())),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### encrypt_str
|
||||
///
|
||||
/// Encrypt provided string using AES-128. Encrypted buffer is then converted to BASE64
|
||||
fn encrypt_str(&self, txt: &str) -> String {
|
||||
crypto::aes128_b64_crypt(self.key.as_str(), txt)
|
||||
}
|
||||
|
||||
/// ### decrypt_str
|
||||
///
|
||||
/// Decrypt provided string using AES-128
|
||||
fn decrypt_str(&self, secret: &str) -> Result<String, SerializerError> {
|
||||
match crypto::aes128_b64_decrypt(self.key.as_str(), secret) {
|
||||
Ok(txt) => Ok(txt),
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::SyntaxError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_os = "macos"))] // CI/CD blocks
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_system_bookmarks_new() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Verify client
|
||||
assert_eq!(client.hosts.bookmarks.len(), 0);
|
||||
assert_eq!(client.hosts.recents.len(), 0);
|
||||
assert_eq!(client.key.len(), 256);
|
||||
assert_eq!(client.bookmarks_file, cfg_path);
|
||||
assert_eq!(client.recents_size, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(any(target_os = "unix", target_os = "linux"))]
|
||||
fn test_system_bookmarks_new_err() {
|
||||
assert!(BookmarksClient::new(
|
||||
Path::new("/tmp/oifoif/omar"),
|
||||
Path::new("/tmp/efnnu/omar"),
|
||||
16
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, _): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
assert!(
|
||||
BookmarksClient::new(cfg_path.as_path(), Path::new("/tmp/efnnu/omar"), 16).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_system_bookmarks_new_from_existing() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let mut client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Add some bookmarks
|
||||
client.add_bookmark(
|
||||
String::from("raspberry"),
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
Some(String::from("mypassword")),
|
||||
);
|
||||
client.add_recent(
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
);
|
||||
let recent_key: String = String::from(client.iter_recents().next().unwrap());
|
||||
assert!(client.write_bookmarks().is_ok());
|
||||
let key: String = client.key.clone();
|
||||
// Re-initialize a client
|
||||
let client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Verify it loaded parameters correctly
|
||||
assert_eq!(client.key, key);
|
||||
let bookmark: (String, u16, FileTransferProtocol, String, Option<String>) =
|
||||
client.get_bookmark(&String::from("raspberry")).unwrap();
|
||||
assert_eq!(bookmark.0, String::from("192.168.1.31"));
|
||||
assert_eq!(bookmark.1, 22);
|
||||
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
|
||||
assert_eq!(bookmark.3, String::from("pi"));
|
||||
assert_eq!(*bookmark.4.as_ref().unwrap(), String::from("mypassword"));
|
||||
let bookmark: (String, u16, FileTransferProtocol, String) =
|
||||
client.get_recent(&recent_key).unwrap();
|
||||
assert_eq!(bookmark.0, String::from("192.168.1.31"));
|
||||
assert_eq!(bookmark.1, 22);
|
||||
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
|
||||
assert_eq!(bookmark.3, String::from("pi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_system_bookmarks_manipulate_bookmarks() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let mut client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Add bookmark
|
||||
client.add_bookmark(
|
||||
String::from("raspberry"),
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
Some(String::from("mypassword")),
|
||||
);
|
||||
client.add_bookmark(
|
||||
String::from("raspberry2"),
|
||||
String::from("192.168.1.32"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
Some(String::from("mypassword2")),
|
||||
);
|
||||
// Iter
|
||||
assert_eq!(client.iter_bookmarks().count(), 2);
|
||||
// Get bookmark
|
||||
let bookmark: (String, u16, FileTransferProtocol, String, Option<String>) =
|
||||
client.get_bookmark(&String::from("raspberry")).unwrap();
|
||||
assert_eq!(bookmark.0, String::from("192.168.1.31"));
|
||||
assert_eq!(bookmark.1, 22);
|
||||
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
|
||||
assert_eq!(bookmark.3, String::from("pi"));
|
||||
assert_eq!(*bookmark.4.as_ref().unwrap(), String::from("mypassword"));
|
||||
// Write bookmarks
|
||||
assert!(client.write_bookmarks().is_ok());
|
||||
// Delete bookmark
|
||||
client.del_bookmark(&String::from("raspberry"));
|
||||
// Get unexisting bookmark
|
||||
assert!(client.get_bookmark(&String::from("raspberry")).is_none());
|
||||
// Write bookmarks
|
||||
assert!(client.write_bookmarks().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
|
||||
fn test_system_bookmarks_bad_bookmark_name() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let mut client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Add bookmark
|
||||
client.add_bookmark(
|
||||
String::from(""),
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
Some(String::from("mypassword")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_system_bookmarks_manipulate_recents() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let mut client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Add bookmark
|
||||
client.add_recent(
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
);
|
||||
// Iter
|
||||
assert_eq!(client.iter_recents().count(), 1);
|
||||
let key: String = String::from(client.iter_recents().next().unwrap());
|
||||
// Get bookmark
|
||||
let bookmark: (String, u16, FileTransferProtocol, String) =
|
||||
client.get_recent(&key).unwrap();
|
||||
assert_eq!(bookmark.0, String::from("192.168.1.31"));
|
||||
assert_eq!(bookmark.1, 22);
|
||||
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
|
||||
assert_eq!(bookmark.3, String::from("pi"));
|
||||
// Write bookmarks
|
||||
assert!(client.write_bookmarks().is_ok());
|
||||
// Delete bookmark
|
||||
client.del_recent(&key);
|
||||
// Get unexisting bookmark
|
||||
assert!(client.get_bookmark(&key).is_none());
|
||||
// Write bookmarks
|
||||
assert!(client.write_bookmarks().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_system_bookmarks_dup_recent() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let mut client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Add bookmark
|
||||
client.add_recent(
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
);
|
||||
client.add_recent(
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
);
|
||||
// There should be only one recent
|
||||
assert_eq!(client.iter_recents().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_system_bookmarks_recents_more_than_limit() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let mut client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 2).unwrap();
|
||||
// Add recent, wait 1 second for each one (cause the name depends on time)
|
||||
// 1
|
||||
client.add_recent(
|
||||
String::from("192.168.1.1"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
);
|
||||
sleep(Duration::from_secs(1));
|
||||
// 2
|
||||
client.add_recent(
|
||||
String::from("192.168.1.2"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
);
|
||||
sleep(Duration::from_secs(1));
|
||||
// 3
|
||||
client.add_recent(
|
||||
String::from("192.168.1.3"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
);
|
||||
// Limit is 2
|
||||
assert_eq!(client.iter_recents().count(), 2);
|
||||
// Check that 192.168.1.1 has been removed
|
||||
let key: String = client.iter_recents().nth(0).unwrap().to_string();
|
||||
assert!(matches!(
|
||||
client.hosts.recents.get(&key).unwrap().address.as_str(),
|
||||
"192.168.1.2" | "192.168.1.3"
|
||||
));
|
||||
let key: String = client.iter_recents().nth(1).unwrap().to_string();
|
||||
assert!(matches!(
|
||||
client.hosts.recents.get(&key).unwrap().address.as_str(),
|
||||
"192.168.1.2" | "192.168.1.3"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
|
||||
fn test_system_bookmarks_add_bookmark_empty() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
// Initialize a new bookmarks client
|
||||
let mut client: BookmarksClient =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
|
||||
// Add bookmark
|
||||
client.add_bookmark(
|
||||
String::from(""),
|
||||
String::from("192.168.1.31"),
|
||||
22,
|
||||
FileTransferProtocol::Sftp,
|
||||
String::from("pi"),
|
||||
Some(String::from("mypassword")),
|
||||
);
|
||||
}
|
||||
|
||||
/// ### get_paths
|
||||
///
|
||||
/// Get paths for configuration and key for bookmarks
|
||||
|
||||
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
|
||||
let k: PathBuf = PathBuf::from(dir);
|
||||
let mut c: PathBuf = k.clone();
|
||||
c.push("bookmarks.toml");
|
||||
(c, k)
|
||||
}
|
||||
|
||||
/// ### create_tmp_dir
|
||||
///
|
||||
/// Create temporary directory
|
||||
|
||||
fn create_tmp_dir() -> tempfile::TempDir {
|
||||
tempfile::TempDir::new().ok().unwrap()
|
||||
}
|
||||
}
|
||||
592
src/system/config_client.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
//! ## ConfigClient
|
||||
//!
|
||||
//! `config_client` is the module which provides an API between the Config module and the system
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Deps
|
||||
extern crate rand;
|
||||
// Locals
|
||||
use crate::config::serializer::ConfigSerializer;
|
||||
use crate::config::{SerializerError, SerializerErrorKind, UserConfig};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::fs::explorer::GroupDirs;
|
||||
// Ext
|
||||
use std::fs::{create_dir, remove_file, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::string::ToString;
|
||||
|
||||
// Types
|
||||
pub type SshHost = (String, String, PathBuf); // 0: host, 1: username, 2: RSA key path
|
||||
|
||||
/// ## ConfigClient
|
||||
///
|
||||
/// ConfigClient provides a high level API to communicate with the termscp configuration
|
||||
pub struct ConfigClient {
|
||||
config: UserConfig, // Configuration loaded
|
||||
config_path: PathBuf, // Configuration TOML Path
|
||||
ssh_key_dir: PathBuf, // SSH Key storage directory
|
||||
}
|
||||
|
||||
impl ConfigClient {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new `ConfigClient` with provided path
|
||||
pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result<ConfigClient, SerializerError> {
|
||||
// Initialize a default configuration
|
||||
let default_config: UserConfig = UserConfig::default();
|
||||
// Create client
|
||||
let mut client: ConfigClient = ConfigClient {
|
||||
config: default_config,
|
||||
config_path: PathBuf::from(config_path),
|
||||
ssh_key_dir: PathBuf::from(ssh_key_dir),
|
||||
};
|
||||
// If ssh key directory doesn't exist, create it
|
||||
if !ssh_key_dir.exists() {
|
||||
if let Err(err) = create_dir(ssh_key_dir) {
|
||||
return Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
format!(
|
||||
"Could not create SSH key directory \"{}\": {}",
|
||||
ssh_key_dir.display(),
|
||||
err
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// If Config file doesn't exist, create it
|
||||
if !config_path.exists() {
|
||||
if let Err(err) = client.write_config() {
|
||||
return Err(err);
|
||||
}
|
||||
} else {
|
||||
// otherwise Load configuration from file
|
||||
if let Err(err) = client.read_config() {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
// Text editor
|
||||
|
||||
/// ### get_text_editor
|
||||
///
|
||||
/// Get text editor from configuration
|
||||
pub fn get_text_editor(&self) -> PathBuf {
|
||||
self.config.user_interface.text_editor.clone()
|
||||
}
|
||||
|
||||
/// ### set_text_editor
|
||||
///
|
||||
/// Set text editor path
|
||||
pub fn set_text_editor(&mut self, path: PathBuf) {
|
||||
self.config.user_interface.text_editor = path;
|
||||
}
|
||||
|
||||
// Default protocol
|
||||
|
||||
/// ### get_default_protocol
|
||||
///
|
||||
/// Get default protocol from configuration
|
||||
pub fn get_default_protocol(&self) -> FileTransferProtocol {
|
||||
match FileTransferProtocol::from_str(self.config.user_interface.default_protocol.as_str()) {
|
||||
Ok(p) => p,
|
||||
Err(_) => FileTransferProtocol::Sftp,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### set_default_protocol
|
||||
///
|
||||
/// Set default protocol to configuration
|
||||
pub fn set_default_protocol(&mut self, proto: FileTransferProtocol) {
|
||||
self.config.user_interface.default_protocol = proto.to_string();
|
||||
}
|
||||
|
||||
/// ### get_show_hidden_files
|
||||
///
|
||||
/// Get value of `show_hidden_files`
|
||||
pub fn get_show_hidden_files(&self) -> bool {
|
||||
self.config.user_interface.show_hidden_files
|
||||
}
|
||||
|
||||
/// ### set_show_hidden_files
|
||||
///
|
||||
/// Set new value for `show_hidden_files`
|
||||
pub fn set_show_hidden_files(&mut self, value: bool) {
|
||||
self.config.user_interface.show_hidden_files = value;
|
||||
}
|
||||
|
||||
/// ### get_check_for_updates
|
||||
///
|
||||
/// Get value of `check_for_updates`
|
||||
pub fn get_check_for_updates(&self) -> bool {
|
||||
self.config.user_interface.check_for_updates.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// ### set_check_for_updates
|
||||
///
|
||||
/// Set new value for `check_for_updates`
|
||||
pub fn set_check_for_updates(&mut self, value: bool) {
|
||||
self.config.user_interface.check_for_updates = Some(value);
|
||||
}
|
||||
|
||||
/// ### get_group_dirs
|
||||
///
|
||||
/// Get GroupDirs value from configuration (will be converted from string)
|
||||
pub fn get_group_dirs(&self) -> Option<GroupDirs> {
|
||||
// Convert string to `GroupDirs`
|
||||
match &self.config.user_interface.group_dirs {
|
||||
None => None,
|
||||
Some(val) => match GroupDirs::from_str(val.as_str()) {
|
||||
Ok(val) => Some(val),
|
||||
Err(_) => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// ### set_group_dirs
|
||||
///
|
||||
/// Set value for group_dir in configuration.
|
||||
/// Provided value, if `Some` will be converted to `GroupDirs`
|
||||
pub fn set_group_dirs(&mut self, val: Option<GroupDirs>) {
|
||||
self.config.user_interface.group_dirs = val.map(|val| val.to_string());
|
||||
}
|
||||
|
||||
/// ### get_file_fmt
|
||||
///
|
||||
/// Get current file fmt
|
||||
pub fn get_file_fmt(&self) -> Option<String> {
|
||||
self.config.user_interface.file_fmt.clone()
|
||||
}
|
||||
|
||||
/// ### set_file_fmt
|
||||
///
|
||||
/// Set file fmt parameter
|
||||
pub fn set_file_fmt(&mut self, s: String) {
|
||||
self.config.user_interface.file_fmt = match s.is_empty() {
|
||||
true => None,
|
||||
false => Some(s),
|
||||
};
|
||||
}
|
||||
|
||||
// SSH Keys
|
||||
|
||||
/// ### save_ssh_key
|
||||
///
|
||||
/// Save a SSH key into configuration.
|
||||
/// This operation also creates the key file in `ssh_key_dir`
|
||||
/// and also commits changes to configuration, to prevent incoerent data
|
||||
pub fn add_ssh_key(
|
||||
&mut self,
|
||||
host: &str,
|
||||
username: &str,
|
||||
ssh_key: &str,
|
||||
) -> Result<(), SerializerError> {
|
||||
let host_name: String = Self::make_ssh_host_key(host, username);
|
||||
// Get key path
|
||||
let ssh_key_path: PathBuf = {
|
||||
let mut p: PathBuf = self.ssh_key_dir.clone();
|
||||
p.push(format!("{}.key", host_name));
|
||||
p
|
||||
};
|
||||
// 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()) {
|
||||
return Self::make_io_err(err);
|
||||
}
|
||||
// Add host to keys
|
||||
self.config.remote.ssh_keys.insert(host_name, ssh_key_path);
|
||||
// Write config
|
||||
self.write_config()
|
||||
}
|
||||
|
||||
/// ### del_ssh_key
|
||||
///
|
||||
/// Delete a ssh key from configuration, using host as key.
|
||||
/// This operation also unlinks the key file in `ssh_key_dir`
|
||||
/// and also commits changes to configuration, to prevent incoerent data
|
||||
pub fn del_ssh_key(&mut self, host: &str, username: &str) -> Result<(), SerializerError> {
|
||||
// Remove key from configuration and get key path
|
||||
let key_path: PathBuf = match self
|
||||
.config
|
||||
.remote
|
||||
.ssh_keys
|
||||
.remove(&Self::make_ssh_host_key(host, username))
|
||||
{
|
||||
Some(p) => p,
|
||||
None => return Ok(()), // Return ok if host doesn't exist
|
||||
};
|
||||
// Remove file
|
||||
if let Err(err) = remove_file(key_path.as_path()) {
|
||||
return Self::make_io_err(err);
|
||||
}
|
||||
// Commit changes to configuration
|
||||
self.write_config()
|
||||
}
|
||||
|
||||
/// ### get_ssh_key
|
||||
///
|
||||
/// Get ssh key from host.
|
||||
/// None is returned if key doesn't exist
|
||||
/// `std::io::Error` is returned in case it was not possible to read the key file
|
||||
pub fn get_ssh_key(&self, mkey: &str) -> std::io::Result<Option<SshHost>> {
|
||||
// Check if Key exists
|
||||
match self.config.remote.ssh_keys.get(mkey) {
|
||||
None => Ok(None),
|
||||
Some(key_path) => {
|
||||
// Get host and username
|
||||
let (host, username): (String, String) = Self::get_ssh_tokens(mkey);
|
||||
// Return key
|
||||
Ok(Some((host, username, PathBuf::from(key_path))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### iter_ssh_keys
|
||||
///
|
||||
/// Get an iterator through hosts in the ssh key storage
|
||||
pub fn iter_ssh_keys(&self) -> impl Iterator<Item = &String> + '_ {
|
||||
Box::new(self.config.remote.ssh_keys.keys())
|
||||
}
|
||||
|
||||
// I/O
|
||||
|
||||
/// ### write_config
|
||||
///
|
||||
/// Write configuration to file
|
||||
pub fn write_config(&self) -> Result<(), SerializerError> {
|
||||
// Open file
|
||||
match OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(self.config_path.as_path())
|
||||
{
|
||||
Ok(writer) => {
|
||||
let serializer: ConfigSerializer = ConfigSerializer {};
|
||||
serializer.serialize(Box::new(writer), &self.config)
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### read_config
|
||||
///
|
||||
/// Read configuration from file (or reload it if already read)
|
||||
pub fn read_config(&mut self) -> Result<(), SerializerError> {
|
||||
// Open bookmarks file for read
|
||||
match OpenOptions::new()
|
||||
.read(true)
|
||||
.open(self.config_path.as_path())
|
||||
{
|
||||
Ok(reader) => {
|
||||
// Deserialize
|
||||
let deserializer: ConfigSerializer = ConfigSerializer {};
|
||||
match deserializer.deserialize(Box::new(reader)) {
|
||||
Ok(config) => {
|
||||
self.config = config;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### make_ssh_host_key
|
||||
///
|
||||
/// Hosts are saved as `username@host` into configuration.
|
||||
/// This method creates the key name, starting from host and username
|
||||
fn make_ssh_host_key(host: &str, username: &str) -> String {
|
||||
format!("{}@{}", username, host)
|
||||
}
|
||||
|
||||
/// ### get_ssh_tokens
|
||||
///
|
||||
/// Get ssh tokens starting from ssh host key
|
||||
/// Panics if key has invalid syntax
|
||||
/// Returns: (host, username)
|
||||
fn get_ssh_tokens(host_key: &str) -> (String, String) {
|
||||
let tokens: Vec<&str> = host_key.split('@').collect();
|
||||
assert_eq!(tokens.len(), 2);
|
||||
(String::from(tokens[1]), String::from(tokens[0]))
|
||||
}
|
||||
|
||||
/// ### make_io_err
|
||||
///
|
||||
/// Make serializer error from `std::io::Error`
|
||||
fn make_io_err(err: std::io::Error) -> Result<(), SerializerError> {
|
||||
Err(SerializerError::new_ex(
|
||||
SerializerErrorKind::IoError,
|
||||
err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::config::UserConfig;
|
||||
use crate::utils::random::random_alphanumeric_with_len;
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
#[test]
|
||||
fn test_system_config_new() {
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, ssh_keys_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
let client: ConfigClient = ConfigClient::new(cfg_path.as_path(), ssh_keys_path.as_path())
|
||||
.ok()
|
||||
.unwrap();
|
||||
// Verify parameters
|
||||
let default_config: UserConfig = UserConfig::default();
|
||||
assert_eq!(client.config.remote.ssh_keys.len(), 0);
|
||||
assert_eq!(
|
||||
client.config.user_interface.default_protocol,
|
||||
default_config.user_interface.default_protocol
|
||||
);
|
||||
assert_eq!(
|
||||
client.config.user_interface.text_editor,
|
||||
default_config.user_interface.text_editor
|
||||
);
|
||||
assert_eq!(client.config_path, cfg_path);
|
||||
assert_eq!(client.ssh_key_dir, ssh_keys_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_new_err() {
|
||||
assert!(
|
||||
ConfigClient::new(Path::new("/tmp/oifoif/omar"), Path::new("/tmp/efnnu/omar"),)
|
||||
.is_err()
|
||||
);
|
||||
let tmp_dir: tempfile::TempDir = create_tmp_dir();
|
||||
let (cfg_path, _): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
assert!(ConfigClient::new(cfg_path.as_path(), Path::new("/tmp/efnnu/omar")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_from_existing() {
|
||||
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();
|
||||
// Change some stuff
|
||||
client.set_text_editor(PathBuf::from("/usr/bin/vim"));
|
||||
client.set_default_protocol(FileTransferProtocol::Scp);
|
||||
assert!(client
|
||||
.add_ssh_key("192.168.1.31", "pi", "piroporopero")
|
||||
.is_ok());
|
||||
assert!(client.write_config().is_ok());
|
||||
// Istantiate a new client
|
||||
let client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
|
||||
.ok()
|
||||
.unwrap();
|
||||
// Verify client has updated parameters
|
||||
assert_eq!(client.get_default_protocol(), FileTransferProtocol::Scp);
|
||||
assert_eq!(client.get_text_editor(), PathBuf::from("/usr/bin/vim"));
|
||||
let mut expected_key_path: PathBuf = key_path.clone();
|
||||
expected_key_path.push("pi@192.168.1.31.key");
|
||||
assert_eq!(
|
||||
client.get_ssh_key("pi@192.168.1.31").unwrap().unwrap(),
|
||||
(
|
||||
String::from("192.168.1.31"),
|
||||
String::from("pi"),
|
||||
expected_key_path,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_text_editor() {
|
||||
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();
|
||||
client.set_text_editor(PathBuf::from("mcedit"));
|
||||
assert_eq!(client.get_text_editor(), PathBuf::from("mcedit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_default_protocol() {
|
||||
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();
|
||||
client.set_default_protocol(FileTransferProtocol::Ftp(true));
|
||||
assert_eq!(
|
||||
client.get_default_protocol(),
|
||||
FileTransferProtocol::Ftp(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_show_hidden_files() {
|
||||
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();
|
||||
client.set_show_hidden_files(true);
|
||||
assert_eq!(client.get_show_hidden_files(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_check_for_updates() {
|
||||
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_check_for_updates(), true); // Null ?
|
||||
client.set_check_for_updates(true);
|
||||
assert_eq!(client.get_check_for_updates(), true);
|
||||
client.set_check_for_updates(false);
|
||||
assert_eq!(client.get_check_for_updates(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_group_dirs() {
|
||||
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();
|
||||
client.set_group_dirs(Some(GroupDirs::First));
|
||||
assert_eq!(client.get_group_dirs(), Some(GroupDirs::First),);
|
||||
client.set_group_dirs(None);
|
||||
assert_eq!(client.get_group_dirs(), None,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_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}"));
|
||||
// Delete
|
||||
client.set_file_fmt(String::from(""));
|
||||
assert_eq!(client.get_file_fmt(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_ssh_keys() {
|
||||
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();
|
||||
// Add a new key
|
||||
let rsa_key: String = get_sample_rsa_key();
|
||||
assert!(client
|
||||
.add_ssh_key("192.168.1.31", "pi", rsa_key.as_str())
|
||||
.is_ok());
|
||||
// Iterate keys
|
||||
for key in client.iter_ssh_keys() {
|
||||
let host: SshHost = client.get_ssh_key(key).ok().unwrap().unwrap();
|
||||
assert_eq!(host.0, String::from("192.168.1.31"));
|
||||
assert_eq!(host.1, String::from("pi"));
|
||||
let mut expected_key_path: PathBuf = key_path.clone();
|
||||
expected_key_path.push("pi@192.168.1.31.key");
|
||||
assert_eq!(host.2, expected_key_path);
|
||||
// Read rsa key
|
||||
let mut key_file: File = File::open(expected_key_path.as_path()).ok().unwrap();
|
||||
// Read
|
||||
let mut key: String = String::new();
|
||||
assert!(key_file.read_to_string(&mut key).is_ok());
|
||||
// Verify rsa key
|
||||
assert_eq!(key, rsa_key);
|
||||
}
|
||||
// Unexisting key
|
||||
assert!(client.get_ssh_key("test").ok().unwrap().is_none());
|
||||
// Delete key
|
||||
assert!(client.del_ssh_key("192.168.1.31", "pi").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_make_key() {
|
||||
assert_eq!(
|
||||
ConfigClient::make_ssh_host_key("192.168.1.31", "pi"),
|
||||
String::from("pi@192.168.1.31")
|
||||
);
|
||||
assert_eq!(
|
||||
ConfigClient::get_ssh_tokens("pi@192.168.1.31"),
|
||||
(String::from("192.168.1.31"), String::from("pi"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_make_io_err() {
|
||||
let err: SerializerError =
|
||||
ConfigClient::make_io_err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(err.to_string(), "IO error (permission denied)");
|
||||
}
|
||||
|
||||
/// ### get_paths
|
||||
///
|
||||
/// Get paths for configuration and keys directory
|
||||
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
|
||||
let mut k: PathBuf = PathBuf::from(dir);
|
||||
let mut c: PathBuf = k.clone();
|
||||
k.push("ssh-keys/");
|
||||
c.push("config.toml");
|
||||
(c, k)
|
||||
}
|
||||
|
||||
/// ### create_tmp_dir
|
||||
///
|
||||
/// Create temporary directory
|
||||
fn create_tmp_dir() -> tempfile::TempDir {
|
||||
tempfile::TempDir::new().ok().unwrap()
|
||||
}
|
||||
|
||||
fn get_sample_rsa_key() -> String {
|
||||
format!(
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\n{}\n-----END OPENSSH PRIVATE KEY-----",
|
||||
random_alphanumeric_with_len(2536)
|
||||
)
|
||||
}
|
||||
}
|
||||
145
src/system/environment.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! ## Environment
|
||||
//!
|
||||
//! `environment` is the module which provides Path and values for the system environment
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Deps
|
||||
extern crate dirs;
|
||||
|
||||
// Ext
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// ### get_config_dir
|
||||
///
|
||||
/// Get termscp configuration directory path.
|
||||
/// Returns None, if it's not possible to get it
|
||||
pub fn init_config_dir() -> Result<Option<PathBuf>, String> {
|
||||
// Get file
|
||||
#[cfg(not(test))]
|
||||
lazy_static! {
|
||||
static ref CONF_DIR: Option<PathBuf> = dirs::config_dir();
|
||||
}
|
||||
#[cfg(test)]
|
||||
lazy_static! {
|
||||
static ref CONF_DIR: Option<PathBuf> = Some(std::env::temp_dir());
|
||||
}
|
||||
if CONF_DIR.is_some() {
|
||||
// Get path of bookmarks
|
||||
let mut p: PathBuf = CONF_DIR.as_ref().unwrap().clone();
|
||||
// Append termscp dir
|
||||
p.push("termscp/");
|
||||
// If directory doesn't exist, create it
|
||||
match p.exists() {
|
||||
true => Ok(Some(p)),
|
||||
false => match std::fs::create_dir(p.as_path()) {
|
||||
Ok(_) => Ok(Some(p)),
|
||||
Err(err) => Err(err.to_string()),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_bookmarks_paths
|
||||
///
|
||||
/// Get paths for bookmarks client
|
||||
/// Returns: path of bookmarks.toml
|
||||
pub fn get_bookmarks_paths(config_dir: &Path) -> PathBuf {
|
||||
// Prepare paths
|
||||
let mut bookmarks_file: PathBuf = PathBuf::from(config_dir);
|
||||
bookmarks_file.push("bookmarks.toml");
|
||||
bookmarks_file
|
||||
}
|
||||
|
||||
/// ### get_config_paths
|
||||
///
|
||||
/// Returns paths for config client
|
||||
/// Returns: path of config.toml and path for ssh keys
|
||||
pub fn get_config_paths(config_dir: &Path) -> (PathBuf, PathBuf) {
|
||||
// Prepare paths
|
||||
let mut bookmarks_file: PathBuf = PathBuf::from(config_dir);
|
||||
bookmarks_file.push("config.toml");
|
||||
let mut keys_dir: PathBuf = PathBuf::from(config_dir);
|
||||
keys_dir.push(".ssh/"); // Path where keys are stored
|
||||
(bookmarks_file, keys_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_system_environment_get_config_dir() {
|
||||
// Create and get conf_dir
|
||||
let conf_dir: PathBuf = init_config_dir().ok().unwrap().unwrap();
|
||||
// Remove dir
|
||||
assert!(std::fs::remove_dir_all(conf_dir.as_path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_environment_get_config_dir_err() {
|
||||
let mut conf_dir: PathBuf = std::env::temp_dir();
|
||||
conf_dir.push("termscp");
|
||||
// Create file
|
||||
let mut f: File = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(conf_dir.as_path())
|
||||
.ok()
|
||||
.unwrap();
|
||||
// Write
|
||||
assert!(writeln!(f, "Hello world!").is_ok());
|
||||
// Drop file
|
||||
drop(f);
|
||||
// Get config dir (will fail)
|
||||
assert!(init_config_dir().is_err());
|
||||
// Remove file
|
||||
assert!(std::fs::remove_file(conf_dir.as_path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_environment_get_bookmarks_paths() {
|
||||
assert_eq!(
|
||||
get_bookmarks_paths(&Path::new("/home/omar/.config/termscp/")),
|
||||
PathBuf::from("/home/omar/.config/termscp/bookmarks.toml"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_environment_get_config_paths() {
|
||||
assert_eq!(
|
||||
get_config_paths(&Path::new("/home/omar/.config/termscp/")),
|
||||
(
|
||||
PathBuf::from("/home/omar/.config/termscp/config.toml"),
|
||||
PathBuf::from("/home/omar/.config/termscp/.ssh/")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/system/keys/filestorage.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! ## FileStorage
|
||||
//!
|
||||
//! `filestorage` provides an implementation of the `KeyStorage` trait using a file
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// Local
|
||||
use super::{KeyStorage, KeyStorageError};
|
||||
// Ext
|
||||
use std::fs::{OpenOptions, Permissions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// ## FileStorage
|
||||
///
|
||||
/// File storage is an implementation o the `KeyStorage` which uses a file to store the key
|
||||
pub struct FileStorage {
|
||||
dir_path: PathBuf,
|
||||
}
|
||||
|
||||
impl FileStorage {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new `FileStorage`
|
||||
pub fn new(dir_path: &Path) -> Self {
|
||||
FileStorage {
|
||||
dir_path: PathBuf::from(dir_path),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### make_file_path
|
||||
///
|
||||
/// Make file path for key file from `dir_path` and the application id
|
||||
fn make_file_path(&self, storage_id: &str) -> PathBuf {
|
||||
let mut p: PathBuf = self.dir_path.clone();
|
||||
let file_name = format!(".{}.key", storage_id);
|
||||
p.push(file_name);
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStorage for FileStorage {
|
||||
/// ### get_key
|
||||
///
|
||||
/// Retrieve key from the key storage.
|
||||
/// The key might be acccess through an identifier, which identifies
|
||||
/// the key in the storage
|
||||
fn get_key(&self, storage_id: &str) -> Result<String, KeyStorageError> {
|
||||
let key_file: PathBuf = self.make_file_path(storage_id);
|
||||
// Check if file exists
|
||||
if !key_file.exists() {
|
||||
return Err(KeyStorageError::NoSuchKey);
|
||||
}
|
||||
// Read key from file
|
||||
match OpenOptions::new().read(true).open(key_file.as_path()) {
|
||||
Ok(mut file) => {
|
||||
let mut key: String = String::new();
|
||||
match file.read_to_string(&mut key) {
|
||||
Ok(_) => Ok(key),
|
||||
Err(_) => Err(KeyStorageError::ProviderError),
|
||||
}
|
||||
}
|
||||
Err(_) => Err(KeyStorageError::ProviderError),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### set_key
|
||||
///
|
||||
/// Set the key into the key storage
|
||||
fn set_key(&self, storage_id: &str, key: &str) -> Result<(), KeyStorageError> {
|
||||
let key_file: PathBuf = self.make_file_path(storage_id);
|
||||
// Write key
|
||||
match OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(key_file.as_path())
|
||||
{
|
||||
Ok(mut file) => {
|
||||
// Write key to file
|
||||
if file.write_all(key.as_bytes()).is_err() {
|
||||
return Err(KeyStorageError::ProviderError);
|
||||
}
|
||||
// Set file to readonly
|
||||
let mut permissions: Permissions = file.metadata().unwrap().permissions();
|
||||
permissions.set_readonly(true);
|
||||
let _ = file.set_permissions(permissions);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Err(KeyStorageError::ProviderError),
|
||||
}
|
||||
}
|
||||
|
||||
/// is_supported
|
||||
///
|
||||
/// Returns whether the key storage is supported on the host system
|
||||
fn is_supported(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_system_keys_filestorage_make_dir() {
|
||||
let storage: FileStorage = FileStorage::new(&Path::new("/tmp/"));
|
||||
assert_eq!(
|
||||
storage.make_file_path("bookmarks").as_path(),
|
||||
Path::new("/tmp/.bookmarks.key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_keys_filestorage_ok() {
|
||||
let key_dir: tempfile::TempDir =
|
||||
tempfile::TempDir::new().expect("Could not create tempdir");
|
||||
let storage: FileStorage = FileStorage::new(key_dir.path());
|
||||
// Supported
|
||||
assert!(storage.is_supported());
|
||||
let app_name: &str = "termscp";
|
||||
let secret: &str = "Th15-15/My-Супер-Секрет";
|
||||
// Secret should not exist
|
||||
assert_eq!(
|
||||
storage.get_key(app_name).err().unwrap(),
|
||||
KeyStorageError::NoSuchKey
|
||||
);
|
||||
// Write secret
|
||||
assert!(storage.set_key(app_name, secret).is_ok());
|
||||
// Get secret
|
||||
assert_eq!(storage.get_key(app_name).ok().unwrap().as_str(), secret);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_keys_filestorage_err() {
|
||||
let bad_dir: &Path = Path::new("/piro/poro/pero/");
|
||||
let storage: FileStorage = FileStorage::new(bad_dir);
|
||||
let app_name: &str = "termscp";
|
||||
let secret: &str = "Th15-15/My-Супер-Секрет";
|
||||
assert!(storage.set_key(app_name, secret).is_err());
|
||||
}
|
||||
}
|
||||
133
src/system/keys/keyringstorage.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
//! ## KeyringStorage
|
||||
//!
|
||||
//! `keyringstorage` provides an implementation of the `KeyStorage` trait using the OS keyring
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Deps
|
||||
extern crate keyring;
|
||||
// Local
|
||||
use super::{KeyStorage, KeyStorageError};
|
||||
// Ext
|
||||
use keyring::{Keyring, KeyringError};
|
||||
|
||||
/// ## KeyringStorage
|
||||
///
|
||||
/// provides a `KeyStorage` implementation using the keyring crate
|
||||
pub struct KeyringStorage {
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
impl KeyringStorage {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new KeyringStorage
|
||||
pub fn new(username: &str) -> Self {
|
||||
KeyringStorage {
|
||||
username: username.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
impl KeyStorage for KeyringStorage {
|
||||
/// ### get_key
|
||||
///
|
||||
/// Retrieve key from the key storage.
|
||||
/// The key might be acccess through an identifier, which identifies
|
||||
/// the key in the storage
|
||||
fn get_key(&self, storage_id: &str) -> Result<String, KeyStorageError> {
|
||||
let storage: Keyring = Keyring::new(storage_id, self.username.as_str());
|
||||
match storage.get_password() {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) => match e {
|
||||
KeyringError::NoPasswordFound => Err(KeyStorageError::NoSuchKey),
|
||||
#[cfg(target_os = "windows")]
|
||||
KeyringError::WindowsVaultError => Err(KeyStorageError::NoSuchKey),
|
||||
#[cfg(target_os = "macos")]
|
||||
KeyringError::MacOsKeychainError(_) => Err(KeyStorageError::NoSuchKey),
|
||||
_ => panic!("{}", e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// ### set_key
|
||||
///
|
||||
/// Set the key into the key storage
|
||||
fn set_key(&self, storage_id: &str, key: &str) -> Result<(), KeyStorageError> {
|
||||
let storage: Keyring = Keyring::new(storage_id, self.username.as_str());
|
||||
match storage.set_password(key) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(KeyStorageError::ProviderError),
|
||||
}
|
||||
}
|
||||
|
||||
/// is_supported
|
||||
///
|
||||
/// Returns whether the key storage is supported on the host system
|
||||
fn is_supported(&self) -> bool {
|
||||
let dummy: String = String::from("dummy-service");
|
||||
let storage: Keyring = Keyring::new(dummy.as_str(), self.username.as_str());
|
||||
// Check what kind of error is returned
|
||||
match storage.get_password() {
|
||||
Ok(_) => true,
|
||||
Err(err) => !matches!(err, KeyringError::NoBackendFound),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
extern crate whoami;
|
||||
use super::*;
|
||||
|
||||
use whoami::username;
|
||||
|
||||
#[test]
|
||||
fn test_system_keys_keyringstorage() {
|
||||
let username: String = username();
|
||||
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
|
||||
assert!(storage.is_supported());
|
||||
let app_name: &str = "termscp-test2";
|
||||
let secret: &str = "Th15-15/My-Супер-Секрет";
|
||||
let kring: Keyring = Keyring::new(app_name, username.as_str());
|
||||
let _ = kring.delete_password();
|
||||
drop(kring);
|
||||
// Secret should not exist
|
||||
assert_eq!(
|
||||
storage.get_key(app_name).err().unwrap(),
|
||||
KeyStorageError::NoSuchKey
|
||||
);
|
||||
// Write secret
|
||||
assert!(storage.set_key(app_name, secret).is_ok());
|
||||
// Get secret
|
||||
assert_eq!(storage.get_key(app_name).ok().unwrap().as_str(), secret);
|
||||
|
||||
// Delete the key manually...
|
||||
let kring: Keyring = Keyring::new(app_name, username.as_str());
|
||||
assert!(kring.delete_password().is_ok());
|
||||
}
|
||||
}
|
||||
92
src/system/keys/mod.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! ## KeyStorage
|
||||
//!
|
||||
//! `keystorage` provides the trait to manipulate to a KeyStorage
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// Storages
|
||||
pub mod filestorage;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
pub mod keyringstorage;
|
||||
|
||||
/// ## KeyStorageError
|
||||
///
|
||||
/// defines the error type for the `KeyStorage`
|
||||
#[derive(PartialEq, std::fmt::Debug)]
|
||||
pub enum KeyStorageError {
|
||||
//BadKey,
|
||||
ProviderError,
|
||||
NoSuchKey,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KeyStorageError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
let err: String = String::from(match &self {
|
||||
//KeyStorageError::BadKey => "Bad key syntax",
|
||||
KeyStorageError::ProviderError => "Provider service error",
|
||||
KeyStorageError::NoSuchKey => "No such key",
|
||||
});
|
||||
write!(f, "{}", err)
|
||||
}
|
||||
}
|
||||
|
||||
/// ## KeyStorage
|
||||
///
|
||||
/// this traits provides the methods to communicate and interact with the key storage.
|
||||
pub trait KeyStorage {
|
||||
/// ### get_key
|
||||
///
|
||||
/// Retrieve key from the key storage.
|
||||
/// The key might be acccess through an identifier, which identifies
|
||||
/// the key in the storage
|
||||
fn get_key(&self, storage_id: &str) -> Result<String, KeyStorageError>;
|
||||
|
||||
/// ### set_key
|
||||
///
|
||||
/// Set the key into the key storage
|
||||
fn set_key(&self, storage_id: &str, key: &str) -> Result<(), KeyStorageError>;
|
||||
|
||||
/// is_supported
|
||||
///
|
||||
/// Returns whether the key storage is supported on the host system
|
||||
fn is_supported(&self) -> bool;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_system_keys_mod_errors() {
|
||||
assert_eq!(
|
||||
format!("{}", KeyStorageError::ProviderError),
|
||||
String::from("Provider service error")
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{}", KeyStorageError::NoSuchKey),
|
||||
String::from("No such key")
|
||||
);
|
||||
}
|
||||
}
|
||||
33
src/system/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! ## System
|
||||
//!
|
||||
//! `system` is the module which contains functions and data types related to current system
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// modules
|
||||
pub mod bookmarks_client;
|
||||
pub mod config_client;
|
||||
pub mod environment;
|
||||
pub(crate) mod keys;
|
||||
pub mod sshkey_storage;
|
||||
142
src/system/sshkey_storage.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
//! ## SshKeyStorage
|
||||
//!
|
||||
//! `SshKeyStorage` is the module which behaves a storage for ssh keys
|
||||
|
||||
/**
|
||||
* 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::config_client::ConfigClient;
|
||||
// Ext
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct SshKeyStorage {
|
||||
hosts: HashMap<String, PathBuf>, // Association between {user}@{host} and RSA key path
|
||||
}
|
||||
|
||||
impl SshKeyStorage {
|
||||
/// ### storage_from_config
|
||||
///
|
||||
/// Create a `SshKeyStorage` starting from a `ConfigClient`
|
||||
pub fn storage_from_config(cfg_client: &ConfigClient) -> Self {
|
||||
let mut hosts: HashMap<String, PathBuf> =
|
||||
HashMap::with_capacity(cfg_client.iter_ssh_keys().count());
|
||||
// Iterate over keys
|
||||
for key in cfg_client.iter_ssh_keys() {
|
||||
match cfg_client.get_ssh_key(key) {
|
||||
Ok(host) => match host {
|
||||
Some((addr, username, rsa_key_path)) => {
|
||||
let key_name: String = Self::make_mapkey(&addr, &username);
|
||||
hosts.insert(key_name, rsa_key_path);
|
||||
}
|
||||
None => continue,
|
||||
},
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
// Return storage
|
||||
SshKeyStorage { hosts }
|
||||
}
|
||||
|
||||
/// ### empty
|
||||
///
|
||||
/// Create an empty ssh key storage; used in case `ConfigClient` is not available
|
||||
pub fn empty() -> Self {
|
||||
SshKeyStorage {
|
||||
hosts: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### resolve
|
||||
///
|
||||
/// Return RSA key path from host and username
|
||||
pub fn resolve(&self, host: &str, username: &str) -> Option<&PathBuf> {
|
||||
let key: String = Self::make_mapkey(host, username);
|
||||
self.hosts.get(&key)
|
||||
}
|
||||
|
||||
/// ### make_mapkey
|
||||
///
|
||||
/// Make mapkey from host and username
|
||||
fn make_mapkey(host: &str, username: &str) -> String {
|
||||
format!("{}@{}", username, host)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::system::config_client::ConfigClient;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_system_sshkey_storage_new() {
|
||||
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();
|
||||
// Add ssh key
|
||||
assert!(client
|
||||
.add_ssh_key("192.168.1.31", "pi", "piroporopero")
|
||||
.is_ok());
|
||||
// Create ssh key storage
|
||||
let storage: SshKeyStorage = SshKeyStorage::storage_from_config(&client);
|
||||
// Verify key exists
|
||||
let mut exp_key_path: PathBuf = key_path.clone();
|
||||
exp_key_path.push("pi@192.168.1.31.key");
|
||||
assert_eq!(
|
||||
*storage.resolve("192.168.1.31", "pi").unwrap(),
|
||||
exp_key_path
|
||||
);
|
||||
// Verify unexisting key
|
||||
assert!(storage.resolve("deskichup", "veeso").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_sshkey_storage_empty() {
|
||||
let storage: SshKeyStorage = SshKeyStorage::empty();
|
||||
assert_eq!(storage.hosts.len(), 0);
|
||||
}
|
||||
|
||||
/// ### get_paths
|
||||
///
|
||||
/// Get paths for configuration and keys directory
|
||||
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
|
||||
let mut k: PathBuf = PathBuf::from(dir);
|
||||
let mut c: PathBuf = k.clone();
|
||||
k.push("ssh-keys/");
|
||||
c.push("config.toml");
|
||||
(c, k)
|
||||
}
|
||||
|
||||
/// ### create_tmp_dir
|
||||
///
|
||||
/// Create temporary directory
|
||||
fn create_tmp_dir() -> tempfile::TempDir {
|
||||
tempfile::TempDir::new().ok().unwrap()
|
||||
}
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
//! ## AuthActivity
|
||||
//!
|
||||
//! `auth_activity` is the module which implements the authentication activity
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
// Dependencies
|
||||
extern crate crossterm;
|
||||
extern crate tui;
|
||||
extern crate unicode_width;
|
||||
|
||||
// locals
|
||||
use super::{Activity, Context};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
|
||||
// Includes
|
||||
use crossterm::event::Event as InputEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use tui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Span, Spans, Text},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Tabs},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// ### InputField
|
||||
///
|
||||
/// InputField describes the current input field to edit
|
||||
#[derive(std::cmp::PartialEq)]
|
||||
enum InputField {
|
||||
Address,
|
||||
Port,
|
||||
Protocol,
|
||||
Username,
|
||||
Password,
|
||||
}
|
||||
|
||||
/// ### InputMode
|
||||
///
|
||||
/// InputMode describes the current input mode
|
||||
/// Each input mode handle the input events in a different way
|
||||
#[derive(std::cmp::PartialEq)]
|
||||
enum InputMode {
|
||||
Text,
|
||||
Popup,
|
||||
}
|
||||
|
||||
/// ### AuthActivity
|
||||
///
|
||||
/// AuthActivity is the data holder for the authentication activity
|
||||
pub struct AuthActivity {
|
||||
pub address: String,
|
||||
pub port: String,
|
||||
pub protocol: FileTransferProtocol,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub submit: bool, // becomes true after user has submitted fields
|
||||
pub quit: bool, // Becomes true if user has pressed esc
|
||||
context: Option<Context>,
|
||||
selected_field: InputField,
|
||||
input_mode: InputMode,
|
||||
popup_message: Option<String>,
|
||||
password_placeholder: String,
|
||||
redraw: bool, // Should ui actually be redrawned?
|
||||
}
|
||||
|
||||
impl AuthActivity {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new AuthActivity
|
||||
pub fn new() -> AuthActivity {
|
||||
AuthActivity {
|
||||
address: String::new(),
|
||||
port: String::from("22"),
|
||||
protocol: FileTransferProtocol::Sftp,
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
submit: false,
|
||||
quit: false,
|
||||
context: None,
|
||||
selected_field: InputField::Address,
|
||||
input_mode: InputMode::Text,
|
||||
popup_message: None,
|
||||
password_placeholder: String::new(),
|
||||
redraw: true, // True at startup
|
||||
}
|
||||
}
|
||||
|
||||
/// ### set_input_mode
|
||||
///
|
||||
/// Update input mode based on current parameters
|
||||
fn select_input_mode(&mut self) -> InputMode {
|
||||
if self.popup_message.is_some() {
|
||||
return InputMode::Popup;
|
||||
}
|
||||
// Default to text
|
||||
InputMode::Text
|
||||
}
|
||||
|
||||
/// ### handle_input_event
|
||||
///
|
||||
/// Handle input event, based on current input mode
|
||||
fn handle_input_event(&mut self, ev: &InputEvent) {
|
||||
match self.input_mode {
|
||||
InputMode::Text => self.handle_input_event_mode_text(ev),
|
||||
InputMode::Popup => self.handle_input_event_mode_popup(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_text
|
||||
///
|
||||
/// Handler for input event when in textmode
|
||||
fn handle_input_event_mode_text(&mut self, ev: &InputEvent) {
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.quit = true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Handle submit
|
||||
// Check form
|
||||
// Check address
|
||||
if self.address.len() == 0 {
|
||||
self.popup_message = Some(String::from("Invalid address"));
|
||||
return;
|
||||
}
|
||||
// Check port
|
||||
// Convert port to number
|
||||
match self.port.parse::<usize>() {
|
||||
Ok(val) => {
|
||||
if val > 65535 {
|
||||
self.popup_message = Some(String::from(
|
||||
"Specified port must be in range 0-65535",
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.popup_message =
|
||||
Some(String::from("Specified port is not a number"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check username
|
||||
//if self.username.len() == 0 {
|
||||
// self.popup_message = Some(String::from("Invalid username"));
|
||||
// return;
|
||||
//}
|
||||
// Everything OK, set enter
|
||||
self.submit = true;
|
||||
self.popup_message =
|
||||
Some(format!("Connecting to {}:{}...", self.address, self.port));
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
// Pop last char
|
||||
match self.selected_field {
|
||||
InputField::Address => {
|
||||
let _ = self.address.pop();
|
||||
}
|
||||
InputField::Password => {
|
||||
let _ = self.password.pop();
|
||||
}
|
||||
InputField::Username => {
|
||||
let _ = self.username.pop();
|
||||
}
|
||||
InputField::Port => {
|
||||
let _ = self.port.pop();
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
};
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// Move item up
|
||||
self.selected_field = match self.selected_field {
|
||||
InputField::Address => InputField::Password, // End of list (wrap)
|
||||
InputField::Port => InputField::Address,
|
||||
InputField::Protocol => InputField::Port,
|
||||
InputField::Username => InputField::Protocol,
|
||||
InputField::Password => InputField::Username,
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Tab => {
|
||||
// Move item down
|
||||
self.selected_field = match self.selected_field {
|
||||
InputField::Address => InputField::Port,
|
||||
InputField::Port => InputField::Protocol,
|
||||
InputField::Protocol => InputField::Username,
|
||||
InputField::Username => InputField::Password,
|
||||
InputField::Password => InputField::Address, // End of list (wrap)
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) => {
|
||||
match self.selected_field {
|
||||
InputField::Address => self.address.push(ch),
|
||||
InputField::Password => self.password.push(ch),
|
||||
InputField::Username => self.username.push(ch),
|
||||
InputField::Port => {
|
||||
// Value must be numeric
|
||||
if ch.is_numeric() {
|
||||
self.port.push(ch);
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
// If current field is Protocol handle event... (move element left)
|
||||
if self.selected_field == InputField::Protocol {
|
||||
self.protocol = match self.protocol {
|
||||
FileTransferProtocol::Sftp => FileTransferProtocol::Ftp(true), // End of list (wrap)
|
||||
FileTransferProtocol::Scp => FileTransferProtocol::Sftp,
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => FileTransferProtocol::Scp,
|
||||
true => FileTransferProtocol::Ftp(false),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
// If current field is Protocol handle event... ( move element right )
|
||||
if self.selected_field == InputField::Protocol {
|
||||
self.protocol = match self.protocol {
|
||||
FileTransferProtocol::Sftp => FileTransferProtocol::Scp,
|
||||
FileTransferProtocol::Scp => FileTransferProtocol::Ftp(false),
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => FileTransferProtocol::Ftp(true),
|
||||
true => FileTransferProtocol::Sftp, // End of list (wrap)
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_text
|
||||
///
|
||||
/// Handler for input event when in popup mode
|
||||
fn handle_input_event_mode_popup(&mut self, ev: &InputEvent) {
|
||||
// Only enter should be allowed here
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
self.popup_message = None; // Hide popup
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### draw_remote_address
|
||||
///
|
||||
/// Draw remote address block
|
||||
fn draw_remote_address(&self) -> Paragraph {
|
||||
Paragraph::new(self.address.as_ref())
|
||||
.style(match self.selected_field {
|
||||
InputField::Address => Style::default().fg(Color::Yellow),
|
||||
_ => Style::default(),
|
||||
})
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title("Remote address"),
|
||||
)
|
||||
}
|
||||
|
||||
/// ### draw_remote_port
|
||||
///
|
||||
/// Draw remote port block
|
||||
fn draw_remote_port(&self) -> Paragraph {
|
||||
Paragraph::new(self.port.as_ref())
|
||||
.style(match self.selected_field {
|
||||
InputField::Port => Style::default().fg(Color::Cyan),
|
||||
_ => Style::default(),
|
||||
})
|
||||
.block(Block::default().borders(Borders::ALL).title("Remote port"))
|
||||
}
|
||||
|
||||
/// ### draw_protocol_select
|
||||
///
|
||||
/// Draw protocol select
|
||||
fn draw_protocol_select(&self) -> Tabs {
|
||||
let protocols: Vec<Spans> = vec![
|
||||
Spans::from("SFTP"),
|
||||
Spans::from("SCP"),
|
||||
Spans::from("FTP"),
|
||||
Spans::from("FTPS"),
|
||||
];
|
||||
let index: usize = match self.protocol {
|
||||
FileTransferProtocol::Sftp => 0,
|
||||
FileTransferProtocol::Scp => 1,
|
||||
FileTransferProtocol::Ftp(ftps) => match ftps {
|
||||
false => 2,
|
||||
true => 3,
|
||||
},
|
||||
};
|
||||
Tabs::new(protocols)
|
||||
.block(Block::default().borders(Borders::ALL).title("Protocol"))
|
||||
.select(index)
|
||||
.style(match self.selected_field {
|
||||
InputField::Protocol => Style::default().fg(Color::Green),
|
||||
_ => Style::default(),
|
||||
})
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.bg(Color::Green)
|
||||
.fg(Color::Black),
|
||||
)
|
||||
}
|
||||
|
||||
/// ### draw_protocol_username
|
||||
///
|
||||
/// Draw username block
|
||||
fn draw_protocol_username(&self) -> Paragraph {
|
||||
Paragraph::new(self.username.as_ref())
|
||||
.style(match self.selected_field {
|
||||
InputField::Username => Style::default().fg(Color::Magenta),
|
||||
_ => Style::default(),
|
||||
})
|
||||
.block(Block::default().borders(Borders::ALL).title("Username"))
|
||||
}
|
||||
|
||||
/// ### draw_protocol_password
|
||||
///
|
||||
/// Draw password block
|
||||
fn draw_protocol_password(&mut self) -> Paragraph {
|
||||
// Create password secret
|
||||
self.password_placeholder = (0..self.password.width()).map(|_| "*").collect::<String>();
|
||||
Paragraph::new(self.password_placeholder.as_ref())
|
||||
.style(match self.selected_field {
|
||||
InputField::Password => Style::default().fg(Color::LightBlue),
|
||||
_ => Style::default(),
|
||||
})
|
||||
.block(Block::default().borders(Borders::ALL).title("Password"))
|
||||
}
|
||||
|
||||
/// ### draw_header
|
||||
///
|
||||
/// Draw header
|
||||
fn draw_header(&self) -> Paragraph {
|
||||
Paragraph::new(" _____ ____ ____ ____ \n|_ _|__ _ __ _ __ ___ / ___| / ___| _ \\ \n | |/ _ \\ '__| '_ ` _ \\\\___ \\| | | |_) |\n | | __/ | | | | | | |___) | |___| __/ \n |_|\\___|_| |_| |_| |_|____/ \\____|_| \n")
|
||||
.style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD))
|
||||
}
|
||||
|
||||
/// ### draw_footer
|
||||
///
|
||||
/// Draw authentication page footer
|
||||
fn draw_footer(&self) -> Paragraph {
|
||||
// Write header
|
||||
let (footer, h_style) = (
|
||||
vec![
|
||||
Span::raw("Press "),
|
||||
Span::styled("<ESC>", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" to exit, "),
|
||||
Span::styled("<UP,DOWN>", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" to change input field, "),
|
||||
Span::styled("<ENTER>", Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" to submit form"),
|
||||
],
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
);
|
||||
let mut footer_text = Text::from(Spans::from(footer));
|
||||
footer_text.patch_style(h_style);
|
||||
Paragraph::new(footer_text)
|
||||
}
|
||||
|
||||
/// ### draw_popup
|
||||
///
|
||||
/// Draw popup block
|
||||
fn draw_popup(&self, r: Rect) -> (Paragraph, Rect) {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((80) / 2),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage((80) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(r);
|
||||
let area: Rect = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((80) / 2),
|
||||
Constraint::Percentage(20),
|
||||
Constraint::Percentage((80) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(popup_layout[1])[1];
|
||||
let popup: Paragraph = Paragraph::new(self.popup_message.as_ref().unwrap().as_ref())
|
||||
.style(Style::default().fg(Color::Red))
|
||||
.block(Block::default().borders(Borders::ALL).title("Alert"));
|
||||
(popup, area)
|
||||
}
|
||||
}
|
||||
|
||||
impl Activity for AuthActivity {
|
||||
/// ### on_create
|
||||
///
|
||||
/// `on_create` is the function which must be called to initialize the activity.
|
||||
/// `on_create` must initialize all the data structures used by the activity
|
||||
/// Context is taken from activity manager and will be released only when activity is destroyed
|
||||
fn on_create(&mut self, context: Context) {
|
||||
// Set context
|
||||
self.context = Some(context);
|
||||
// Clear terminal
|
||||
let _ = self.context.as_mut().unwrap().terminal.clear();
|
||||
// Put raw mode on enabled
|
||||
let _ = enable_raw_mode();
|
||||
}
|
||||
|
||||
/// ### on_draw
|
||||
///
|
||||
/// `on_draw` is the function which draws the graphical interface.
|
||||
/// This function must be called at each tick to refresh the interface
|
||||
fn on_draw(&mut self) {
|
||||
// Context must be something
|
||||
if self.context.is_none() {
|
||||
return;
|
||||
}
|
||||
// Start catching Input Events
|
||||
if let Ok(input_events) = self.context.as_ref().unwrap().input_hnd.fetch_events() {
|
||||
if input_events.len() > 0 {
|
||||
self.redraw = true; // Set redraw to true if there is at least one event
|
||||
}
|
||||
// Iterate over input events
|
||||
for event in input_events.iter() {
|
||||
self.handle_input_event(event);
|
||||
}
|
||||
}
|
||||
// Redraw if necessary
|
||||
if self.redraw {
|
||||
// Determine input mode
|
||||
self.input_mode = self.select_input_mode();
|
||||
// draw interface
|
||||
let mut ctx: Context = self.context.take().unwrap();
|
||||
let _ = ctx.terminal.draw(|f| {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(2)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(5),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(f.size());
|
||||
// Draw header
|
||||
f.render_widget(self.draw_header(), chunks[0]);
|
||||
// Draw input fields
|
||||
f.render_widget(self.draw_remote_address(), chunks[1]);
|
||||
f.render_widget(self.draw_remote_port(), chunks[2]);
|
||||
f.render_widget(self.draw_protocol_select(), chunks[3]);
|
||||
f.render_widget(self.draw_protocol_username(), chunks[4]);
|
||||
f.render_widget(self.draw_protocol_password(), chunks[5]);
|
||||
// Draw footer
|
||||
f.render_widget(self.draw_footer(), chunks[6]);
|
||||
if self.popup_message.is_some() {
|
||||
let (popup, popup_area): (Paragraph, Rect) = self.draw_popup(f.size());
|
||||
f.render_widget(Clear, popup_area); //this clears out the background
|
||||
f.render_widget(popup, popup_area);
|
||||
}
|
||||
// Set cursor
|
||||
match self.selected_field {
|
||||
InputField::Address => f.set_cursor(
|
||||
chunks[1].x + self.address.width() as u16 + 1,
|
||||
chunks[1].y + 1,
|
||||
),
|
||||
InputField::Port => {
|
||||
f.set_cursor(chunks[2].x + self.port.width() as u16 + 1, chunks[2].y + 1)
|
||||
}
|
||||
InputField::Username => f.set_cursor(
|
||||
chunks[4].x + self.username.width() as u16 + 1,
|
||||
chunks[4].y + 1,
|
||||
),
|
||||
InputField::Password => f.set_cursor(
|
||||
chunks[5].x + self.password_placeholder.width() as u16 + 1,
|
||||
chunks[5].y + 1,
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
// Reset ctx
|
||||
self.context = Some(ctx);
|
||||
// Set redraw to false
|
||||
self.redraw = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### on_destroy
|
||||
///
|
||||
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
|
||||
/// This function must be called once before terminating the activity.
|
||||
/// This function finally releases the context
|
||||
fn on_destroy(&mut self) -> Option<Context> {
|
||||
// Disable raw mode
|
||||
let _ = disable_raw_mode();
|
||||
if self.context.is_none() {
|
||||
return None;
|
||||
}
|
||||
// Clear terminal and return
|
||||
match self.context.take() {
|
||||
Some(mut ctx) => {
|
||||
let _ = ctx.terminal.clear();
|
||||
Some(ctx)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
279
src/ui/activities/auth_activity/bookmarks.rs
Normal file
@@ -0,0 +1,279 @@
|
||||
//! ## 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.
|
||||
*/
|
||||
// Dependencies
|
||||
extern crate dirs;
|
||||
|
||||
// Locals
|
||||
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;
|
||||
|
||||
impl AuthActivity {
|
||||
/// ### del_bookmark
|
||||
///
|
||||
/// Delete bookmark
|
||||
pub(super) fn del_bookmark(&mut self, idx: usize) {
|
||||
if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() {
|
||||
// Iterate over kyes
|
||||
let name: Option<&String> = self.bookmarks_list.get(idx);
|
||||
if let Some(name) = name {
|
||||
bookmarks_cli.del_bookmark(&name);
|
||||
// Write bookmarks
|
||||
self.write_bookmarks();
|
||||
}
|
||||
// Delete element from vec
|
||||
self.bookmarks_list.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### load_bookmark
|
||||
///
|
||||
/// Load selected bookmark (at index) to input fields
|
||||
pub(super) fn load_bookmark(&mut self, idx: usize) {
|
||||
if let Some(bookmarks_cli) = self.bookmarks_client.as_ref() {
|
||||
// Iterate over bookmarks
|
||||
if let Some(key) = self.bookmarks_list.get(idx) {
|
||||
if let Some(bookmark) = bookmarks_cli.get_bookmark(&key) {
|
||||
// Load parameters into components
|
||||
self.load_bookmark_into_gui(
|
||||
bookmark.0, bookmark.1, bookmark.2, bookmark.3, bookmark.4,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### save_bookmark
|
||||
///
|
||||
/// Save current input fields as a bookmark
|
||||
pub(super) fn save_bookmark(&mut self, name: String, save_password: bool) {
|
||||
let (address, port, protocol, username, password) = self.get_input();
|
||||
if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() {
|
||||
// Check if password must be saved
|
||||
let password: Option<String> = match save_password {
|
||||
true => match self
|
||||
.view
|
||||
.get_value(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD)
|
||||
{
|
||||
Some(Payload::Unsigned(0)) => Some(password), // Yes
|
||||
_ => None, // No such component / No
|
||||
},
|
||||
false => None,
|
||||
};
|
||||
bookmarks_cli.add_bookmark(name.clone(), address, port, protocol, username, password);
|
||||
// Save bookmarks
|
||||
self.write_bookmarks();
|
||||
// Remove `name` from bookmarks if exists
|
||||
self.bookmarks_list.retain(|b| b.as_str() != name.as_str());
|
||||
// Push bookmark to list and sort
|
||||
self.bookmarks_list.push(name);
|
||||
self.sort_bookmarks();
|
||||
}
|
||||
}
|
||||
/// ### del_recent
|
||||
///
|
||||
/// Delete recent
|
||||
pub(super) fn del_recent(&mut self, idx: usize) {
|
||||
if let Some(client) = self.bookmarks_client.as_mut() {
|
||||
let name: Option<&String> = self.recents_list.get(idx);
|
||||
if let Some(name) = name {
|
||||
client.del_recent(&name);
|
||||
// Write bookmarks
|
||||
self.write_bookmarks();
|
||||
}
|
||||
// Delete element from vec
|
||||
self.recents_list.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### load_recent
|
||||
///
|
||||
/// Load selected recent (at index) to input fields
|
||||
pub(super) fn load_recent(&mut self, idx: usize) {
|
||||
if let Some(client) = self.bookmarks_client.as_ref() {
|
||||
// Iterate over bookmarks
|
||||
if let Some(key) = self.recents_list.get(idx) {
|
||||
if let Some(bookmark) = client.get_recent(key) {
|
||||
// Load parameters
|
||||
self.load_bookmark_into_gui(
|
||||
bookmark.0, bookmark.1, bookmark.2, bookmark.3, None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### save_recent
|
||||
///
|
||||
/// Save current input fields as a "recent"
|
||||
pub(super) fn save_recent(&mut self) {
|
||||
let (address, port, protocol, username, _password) = self.get_input();
|
||||
if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() {
|
||||
bookmarks_cli.add_recent(address, port, protocol, username);
|
||||
// Save bookmarks
|
||||
self.write_bookmarks();
|
||||
}
|
||||
}
|
||||
|
||||
/// ### write_bookmarks
|
||||
///
|
||||
/// Write bookmarks to file
|
||||
fn write_bookmarks(&mut self) {
|
||||
if let Some(bookmarks_cli) = self.bookmarks_client.as_ref() {
|
||||
if let Err(err) = bookmarks_cli.write_bookmarks() {
|
||||
self.mount_error(format!("Could not write bookmarks: {}", err).as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### init_bookmarks_client
|
||||
///
|
||||
/// Initialize bookmarks client
|
||||
pub(super) fn init_bookmarks_client(&mut self) {
|
||||
// Get config dir
|
||||
match environment::init_config_dir() {
|
||||
Ok(path) => {
|
||||
// If some configure client, otherwise do nothing; don't bother users telling them that bookmarks are not supported on their system.
|
||||
if let Some(config_dir_path) = path {
|
||||
let bookmarks_file: PathBuf =
|
||||
environment::get_bookmarks_paths(config_dir_path.as_path());
|
||||
// Initialize client
|
||||
match BookmarksClient::new(
|
||||
bookmarks_file.as_path(),
|
||||
config_dir_path.as_path(),
|
||||
16,
|
||||
) {
|
||||
Ok(cli) => {
|
||||
// Load bookmarks into list
|
||||
let mut bookmarks_list: Vec<String> =
|
||||
Vec::with_capacity(cli.iter_bookmarks().count());
|
||||
for bookmark in cli.iter_bookmarks() {
|
||||
bookmarks_list.push(bookmark.clone());
|
||||
}
|
||||
// Load recents into list
|
||||
let mut recents_list: Vec<String> =
|
||||
Vec::with_capacity(cli.iter_recents().count());
|
||||
for recent in cli.iter_recents() {
|
||||
recents_list.push(recent.clone());
|
||||
}
|
||||
self.bookmarks_client = Some(cli);
|
||||
self.bookmarks_list = bookmarks_list;
|
||||
self.recents_list = recents_list;
|
||||
// Sort bookmark list
|
||||
self.sort_bookmarks();
|
||||
self.sort_recents();
|
||||
}
|
||||
Err(err) => {
|
||||
self.mount_error(
|
||||
format!(
|
||||
"Could not initialize bookmarks (at \"{}\", \"{}\"): {}",
|
||||
bookmarks_file.display(),
|
||||
config_dir_path.display(),
|
||||
err
|
||||
)
|
||||
.as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.mount_error(
|
||||
format!("Could not initialize configuration directory: {}", err).as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- privates
|
||||
|
||||
/// ### sort_bookmarks
|
||||
///
|
||||
/// Sort bookmarks in list
|
||||
fn sort_bookmarks(&mut self) {
|
||||
// Conver to lowercase when sorting
|
||||
self.bookmarks_list
|
||||
.sort_by(|a, b| a.to_lowercase().as_str().cmp(b.to_lowercase().as_str()));
|
||||
}
|
||||
|
||||
/// ### sort_recents
|
||||
///
|
||||
/// Sort recents in list
|
||||
fn sort_recents(&mut self) {
|
||||
// Reverse order
|
||||
self.recents_list.sort_by(|a, b| b.cmp(a));
|
||||
}
|
||||
|
||||
/// ### load_bookmark_into_gui
|
||||
///
|
||||
/// Load bookmark data into the gui components
|
||||
fn load_bookmark_into_gui(
|
||||
&mut self,
|
||||
addr: String,
|
||||
port: u16,
|
||||
protocol: FileTransferProtocol,
|
||||
username: String,
|
||||
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();
|
||||
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();
|
||||
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,
|
||||
}))
|
||||
.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();
|
||||
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();
|
||||
self.view.update(super::COMPONENT_INPUT_PASSWORD, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
225
src/ui/activities/auth_activity/mod.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! ## 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.
|
||||
*/
|
||||
// Sub modules
|
||||
mod bookmarks;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
// Dependencies
|
||||
extern crate crossterm;
|
||||
extern crate tui;
|
||||
|
||||
// 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};
|
||||
|
||||
// -- components
|
||||
const COMPONENT_TEXT_HEADER: &str = "TEXT_HEADER";
|
||||
const COMPONENT_TEXT_NEW_VERSION: &str = "TEXT_NEW_VERSION";
|
||||
const COMPONENT_TEXT_FOOTER: &str = "TEXT_FOOTER";
|
||||
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
|
||||
const COMPONENT_TEXT_ERROR: &str = "TEXT_ERROR";
|
||||
const COMPONENT_INPUT_ADDR: &str = "INPUT_ADDRESS";
|
||||
const COMPONENT_INPUT_PORT: &str = "INPUT_PORT";
|
||||
const COMPONENT_INPUT_USERNAME: &str = "INPUT_USERNAME";
|
||||
const COMPONENT_INPUT_PASSWORD: &str = "INPUT_PASSWORD";
|
||||
const COMPONENT_INPUT_BOOKMARK_NAME: &str = "INPUT_BOOKMARK_NAME";
|
||||
const COMPONENT_RADIO_PROTOCOL: &str = "RADIO_PROTOCOL";
|
||||
const COMPONENT_RADIO_QUIT: &str = "RADIO_QUIT";
|
||||
const COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK: &str = "RADIO_DELETE_BOOKMARK";
|
||||
const COMPONENT_RADIO_BOOKMARK_DEL_RECENT: &str = "RADIO_DELETE_RECENT";
|
||||
const COMPONENT_RADIO_BOOKMARK_SAVE_PWD: &str = "RADIO_SAVE_PASSWORD";
|
||||
const COMPONENT_BOOKMARKS_LIST: &str = "BOOKMARKS_LIST";
|
||||
const COMPONENT_RECENTS_LIST: &str = "RECENTS_LIST";
|
||||
|
||||
// Store keys
|
||||
const STORE_KEY_LATEST_VERSION: &str = "AUTH_LATEST_VERSION";
|
||||
|
||||
/// ### AuthActivity
|
||||
///
|
||||
/// AuthActivity is the data holder for the authentication activity
|
||||
pub struct AuthActivity {
|
||||
exit_reason: Option<ExitReason>,
|
||||
context: Option<Context>,
|
||||
view: View,
|
||||
bookmarks_client: Option<BookmarksClient>,
|
||||
redraw: bool, // Should ui actually be redrawned?
|
||||
bookmarks_list: Vec<String>, // List of bookmarks
|
||||
recents_list: Vec<String>, // list of recents
|
||||
}
|
||||
|
||||
impl Default for AuthActivity {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthActivity {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new AuthActivity
|
||||
pub fn new() -> AuthActivity {
|
||||
AuthActivity {
|
||||
exit_reason: None,
|
||||
context: None,
|
||||
view: View::init(),
|
||||
bookmarks_client: None,
|
||||
redraw: true, // True at startup
|
||||
bookmarks_list: Vec::new(),
|
||||
recents_list: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### on_create
|
||||
///
|
||||
/// If enabled in configuration, check for updates from Github
|
||||
fn check_for_updates(&mut self) {
|
||||
// Check version only if unset in the store
|
||||
let ctx: &Context = self.context.as_ref().unwrap();
|
||||
if !ctx.store.isset(STORE_KEY_LATEST_VERSION) {
|
||||
let mut new_version: Option<String> = match ctx.config_client.as_ref() {
|
||||
Some(client) => {
|
||||
if client.get_check_for_updates() {
|
||||
// Send request
|
||||
match git::check_for_updates(env!("CARGO_PKG_VERSION")) {
|
||||
Ok(version) => version,
|
||||
Err(err) => {
|
||||
// Report error
|
||||
self.mount_error(
|
||||
format!("Could not check for new updates: {}", err).as_str(),
|
||||
);
|
||||
// None
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let ctx: &mut Context = self.context.as_mut().unwrap();
|
||||
// Set version into the store (or just a flag)
|
||||
match new_version.take() {
|
||||
Some(new_version) => ctx.store.set_string(STORE_KEY_LATEST_VERSION, new_version), // If Some, set String
|
||||
None => ctx.store.set(STORE_KEY_LATEST_VERSION), // If None, just set flag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Activity for AuthActivity {
|
||||
/// ### on_create
|
||||
///
|
||||
/// `on_create` is the function which must be called to initialize the activity.
|
||||
/// `on_create` must initialize all the data structures used by the activity
|
||||
/// Context is taken from activity manager and will be released only when activity is destroyed
|
||||
fn on_create(&mut self, mut context: Context) {
|
||||
// Initialize file transfer params
|
||||
context.ft_params = Some(FileTransferParams::default());
|
||||
// Set context
|
||||
self.context = Some(context);
|
||||
// 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 check for updates is enabled, check for updates
|
||||
self.check_for_updates();
|
||||
// Initialize view
|
||||
self.init();
|
||||
}
|
||||
|
||||
/// ### on_draw
|
||||
///
|
||||
/// `on_draw` is the function which draws the graphical interface.
|
||||
/// This function must be called at each tick to refresh the interface
|
||||
fn on_draw(&mut self) {
|
||||
// Context must be something
|
||||
if self.context.is_none() {
|
||||
return;
|
||||
}
|
||||
// Read one event
|
||||
if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() {
|
||||
// Set redraw to true
|
||||
self.redraw = true;
|
||||
// Handle event on view and update
|
||||
let msg = self.view.on(event);
|
||||
self.update(msg);
|
||||
}
|
||||
// Redraw if necessary
|
||||
if self.redraw {
|
||||
// View
|
||||
self.view();
|
||||
// Set redraw to false
|
||||
self.redraw = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### will_umount
|
||||
///
|
||||
/// `will_umount` is the method which must be able to report to the activity manager, whether
|
||||
/// the activity should be terminated or not.
|
||||
/// If not, the call will return `None`, otherwise return`Some(ExitReason)`
|
||||
fn will_umount(&self) -> Option<&ExitReason> {
|
||||
self.exit_reason.as_ref()
|
||||
}
|
||||
|
||||
/// ### on_destroy
|
||||
///
|
||||
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
|
||||
/// This function must be called once before terminating the activity.
|
||||
/// This function finally releases the context
|
||||
fn on_destroy(&mut self) -> Option<Context> {
|
||||
// Disable raw mode
|
||||
let _ = disable_raw_mode();
|
||||
self.context.as_ref()?;
|
||||
// Clear terminal and return
|
||||
match self.context.take() {
|
||||
Some(mut ctx) => {
|
||||
ctx.clear_screen();
|
||||
Some(ctx)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
346
src/ui/activities/auth_activity/update.rs
Normal file
@@ -0,0 +1,346 @@
|
||||
//! ## 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.
|
||||
*/
|
||||
// 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,
|
||||
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};
|
||||
|
||||
// -- update
|
||||
|
||||
impl AuthActivity {
|
||||
/// ### update
|
||||
///
|
||||
/// Update auth activity model based on msg
|
||||
/// The function exits when returns None
|
||||
pub(super) fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> {
|
||||
let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg));
|
||||
// Match msg
|
||||
match ref_msg {
|
||||
None => None, // Exit after None
|
||||
Some(msg) => match msg {
|
||||
// Focus ( DOWN )
|
||||
(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
|
||||
}
|
||||
(COMPONENT_INPUT_USERNAME, &MSG_KEY_DOWN) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_PASSWORD);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_PASSWORD, &MSG_KEY_DOWN) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_ADDR);
|
||||
None
|
||||
}
|
||||
// Focus ( UP )
|
||||
(COMPONENT_INPUT_PASSWORD, &MSG_KEY_UP) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_USERNAME);
|
||||
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
|
||||
}
|
||||
(COMPONENT_INPUT_PORT, &MSG_KEY_UP) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_ADDR);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_ADDR, &MSG_KEY_UP) => {
|
||||
// Give focus to port
|
||||
self.view.active(COMPONENT_INPUT_PASSWORD);
|
||||
None
|
||||
}
|
||||
// <TAB> bookmarks
|
||||
(COMPONENT_BOOKMARKS_LIST, &MSG_KEY_TAB)
|
||||
| (COMPONENT_RECENTS_LIST, &MSG_KEY_TAB) => {
|
||||
// Give focus to address
|
||||
self.view.active(COMPONENT_INPUT_ADDR);
|
||||
None
|
||||
}
|
||||
// Any <TAB>, go to bookmarks
|
||||
(_, &MSG_KEY_TAB) => {
|
||||
self.view.active(COMPONENT_BOOKMARKS_LIST);
|
||||
None
|
||||
}
|
||||
// Bookmarks commands
|
||||
// <RIGHT> / <LEFT>
|
||||
(COMPONENT_BOOKMARKS_LIST, &MSG_KEY_RIGHT) => {
|
||||
// Give focus to recents
|
||||
self.view.active(COMPONENT_RECENTS_LIST);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RECENTS_LIST, &MSG_KEY_LEFT) => {
|
||||
// Give focus to bookmarks
|
||||
self.view.active(COMPONENT_BOOKMARKS_LIST);
|
||||
None
|
||||
}
|
||||
// <DEL | 'E'>
|
||||
(COMPONENT_BOOKMARKS_LIST, &MSG_KEY_DEL)
|
||||
| (COMPONENT_BOOKMARKS_LIST, &MSG_KEY_CHAR_E) => {
|
||||
// Show delete popup
|
||||
self.mount_bookmark_del_dialog();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RECENTS_LIST, &MSG_KEY_DEL)
|
||||
| (COMPONENT_RECENTS_LIST, &MSG_KEY_CHAR_E) => {
|
||||
// Show delete popup
|
||||
self.mount_recent_del_dialog();
|
||||
None
|
||||
}
|
||||
// Enter
|
||||
(COMPONENT_BOOKMARKS_LIST, Msg::OnSubmit(Payload::Unsigned(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))) => {
|
||||
self.load_recent(*idx);
|
||||
// Give focus to input password
|
||||
self.view.active(COMPONENT_INPUT_PASSWORD);
|
||||
None
|
||||
}
|
||||
// Bookmark radio
|
||||
// Del bookmarks
|
||||
(
|
||||
COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK,
|
||||
Msg::OnSubmit(Payload::Unsigned(index)),
|
||||
) => {
|
||||
// hide bookmark delete
|
||||
self.umount_bookmark_del_dialog();
|
||||
// Index must be 0 => YES
|
||||
match *index {
|
||||
0 => {
|
||||
// Get selected bookmark
|
||||
match self.view.get_value(COMPONENT_BOOKMARKS_LIST) {
|
||||
Some(Payload::Unsigned(index)) => {
|
||||
// Delete bookmark
|
||||
self.del_bookmark(index);
|
||||
// Update bookmarks
|
||||
self.view_bookmarks()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(COMPONENT_RADIO_BOOKMARK_DEL_RECENT, Msg::OnSubmit(Payload::Unsigned(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)) => {
|
||||
// Delete recent
|
||||
self.del_recent(index);
|
||||
// Update bookmarks
|
||||
self.view_recent_connections()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// <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,
|
||||
}
|
||||
}
|
||||
(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,
|
||||
}
|
||||
}
|
||||
// Help
|
||||
(_, &MSG_KEY_CTRL_H) => {
|
||||
// Show help
|
||||
self.mount_help();
|
||||
None
|
||||
}
|
||||
(COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) | (COMPONENT_TEXT_HELP, &MSG_KEY_ESC) => {
|
||||
// Hide text help
|
||||
self.umount_help();
|
||||
None
|
||||
}
|
||||
// Enter setup
|
||||
(_, &MSG_KEY_CTRL_C) => {
|
||||
self.exit_reason = Some(super::ExitReason::EnterSetup);
|
||||
None
|
||||
}
|
||||
// Save bookmark; show popup
|
||||
(_, &MSG_KEY_CTRL_S) => {
|
||||
// Show popup
|
||||
self.mount_bookmark_save_dialog();
|
||||
// Give focus to bookmark name
|
||||
self.view.active(COMPONENT_INPUT_BOOKMARK_NAME);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_BOOKMARK_NAME, &MSG_KEY_DOWN) => {
|
||||
// Give focus to pwd
|
||||
self.view.active(COMPONENT_RADIO_BOOKMARK_SAVE_PWD);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_BOOKMARK_SAVE_PWD, &MSG_KEY_UP) => {
|
||||
// Give focus to pwd
|
||||
self.view.active(COMPONENT_INPUT_BOOKMARK_NAME);
|
||||
None
|
||||
}
|
||||
// Save bookmark
|
||||
(COMPONENT_INPUT_BOOKMARK_NAME, Msg::OnSubmit(_))
|
||||
| (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,
|
||||
_ => String::new(),
|
||||
};
|
||||
let save_pwd: bool = matches!(
|
||||
self.view.get_value(COMPONENT_RADIO_BOOKMARK_SAVE_PWD),
|
||||
Some(Payload::Unsigned(0))
|
||||
);
|
||||
// Save bookmark
|
||||
self.save_bookmark(bookmark_name, save_pwd);
|
||||
// Umount popup
|
||||
self.umount_bookmark_save_dialog();
|
||||
// Reload bookmarks
|
||||
self.view_bookmarks()
|
||||
}
|
||||
// Hide save bookmark
|
||||
(COMPONENT_INPUT_BOOKMARK_NAME, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_BOOKMARK_SAVE_PWD, &MSG_KEY_ESC) => {
|
||||
// Umount popup
|
||||
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))) => {
|
||||
// If choice is 0, quit termscp
|
||||
if *choice == 0 {
|
||||
self.exit_reason = Some(super::ExitReason::Quit);
|
||||
}
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_QUIT, &MSG_KEY_ESC) => {
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
// On submit on any unhandled (connect)
|
||||
(_, Msg::OnSubmit(_)) | (_, &MSG_KEY_ENTER) => {
|
||||
// Match <ENTER> key for all other components
|
||||
self.save_recent();
|
||||
let (address, port, protocol, username, password) = self.get_input();
|
||||
// Set file transfer params to context
|
||||
let mut ft_params: &mut FileTransferParams =
|
||||
&mut self.context.as_mut().unwrap().ft_params.as_mut().unwrap();
|
||||
ft_params.address = address;
|
||||
ft_params.port = port;
|
||||
ft_params.protocol = protocol;
|
||||
ft_params.username = match username.is_empty() {
|
||||
true => None,
|
||||
false => Some(username),
|
||||
};
|
||||
ft_params.password = match password.is_empty() {
|
||||
true => None,
|
||||
false => Some(password),
|
||||
};
|
||||
// Set exit reason
|
||||
self.exit_reason = Some(super::ExitReason::Connect);
|
||||
// Return None
|
||||
None
|
||||
}
|
||||
// <ESC> => Quit
|
||||
(_, &MSG_KEY_ESC) => {
|
||||
self.mount_quit();
|
||||
None
|
||||
}
|
||||
(_, _) => None, // Ignore other events
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
711
src/ui/activities/auth_activity/view.rs
Normal file
@@ -0,0 +1,711 @@
|
||||
//! ## 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.
|
||||
*/
|
||||
// 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::layout::props::{
|
||||
InputType, PropValue, PropsBuilder, TableBuilder, TextParts, TextSpan, TextSpanBuilder,
|
||||
};
|
||||
use crate::ui::layout::utils::draw_area_in;
|
||||
use crate::ui::layout::{Msg, Payload};
|
||||
// Ext
|
||||
use tui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
widgets::Clear,
|
||||
};
|
||||
|
||||
impl AuthActivity {
|
||||
/// ### init
|
||||
///
|
||||
/// 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()
|
||||
)
|
||||
));
|
||||
// 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(),
|
||||
]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Address
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_ADDR,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Yellow)
|
||||
.with_texts(TextParts::new(Some(String::from("Remote address")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Port
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_PORT,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightCyan)
|
||||
.with_texts(TextParts::new(Some(String::from("Port number")), None))
|
||||
.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"),
|
||||
]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Username
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_USERNAME,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightMagenta)
|
||||
.with_texts(TextParts::new(Some(String::from("Username")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Password
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_PASSWORD,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightBlue)
|
||||
.with_texts(TextParts::new(Some(String::from("Password")), None))
|
||||
.with_input(InputType::Password)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Version notice
|
||||
if let Some(version) = self
|
||||
.context
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store
|
||||
.get_string(super::STORE_KEY_LATEST_VERSION)
|
||||
{
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_NEW_VERSION,
|
||||
Box::new(Text::new(
|
||||
PropsBuilder::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))])))
|
||||
.build()
|
||||
))
|
||||
);
|
||||
}
|
||||
// Bookmarks
|
||||
self.view.mount(
|
||||
super::COMPONENT_BOOKMARKS_LIST,
|
||||
Box::new(BookmarkList::new(
|
||||
PropsBuilder::default()
|
||||
.with_background(Color::LightGreen)
|
||||
.with_foreground(Color::Black)
|
||||
.with_texts(TextParts::new(Some(String::from("Bookmarks")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
let _ = self.view_bookmarks();
|
||||
// Recents
|
||||
self.view.mount(
|
||||
super::COMPONENT_RECENTS_LIST,
|
||||
Box::new(BookmarkList::new(
|
||||
PropsBuilder::default()
|
||||
.with_background(Color::LightBlue)
|
||||
.with_foreground(Color::Black)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Recent connections")),
|
||||
None,
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
let _ = self.view_recent_connections();
|
||||
// Active address
|
||||
self.view.active(super::COMPONENT_INPUT_ADDR);
|
||||
}
|
||||
|
||||
/// ### view
|
||||
///
|
||||
/// Display view on canvas
|
||||
pub(super) fn view(&mut self) {
|
||||
let mut ctx: Context = self.context.take().unwrap();
|
||||
let _ = ctx.terminal.draw(|f| {
|
||||
// Prepare chunks
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage(70), // Auth Form
|
||||
Constraint::Percentage(30), // Bookmarks
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(f.size());
|
||||
// Create explorer chunks
|
||||
let auth_chunks = Layout::default()
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(6), // header
|
||||
Constraint::Length(1), // Version
|
||||
Constraint::Length(3), // host
|
||||
Constraint::Length(3), // port
|
||||
Constraint::Length(3), // protocol
|
||||
Constraint::Length(3), // username
|
||||
Constraint::Length(3), // password
|
||||
Constraint::Length(3), // footer
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.direction(Direction::Vertical)
|
||||
.split(chunks[0]);
|
||||
// Create bookmark chunks
|
||||
let bookmark_chunks = Layout::default()
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||
.direction(Direction::Horizontal)
|
||||
.split(chunks[1]);
|
||||
// Render
|
||||
// Auth chunks
|
||||
self.view
|
||||
.render(super::COMPONENT_TEXT_HEADER, f, auth_chunks[0]);
|
||||
self.view
|
||||
.render(super::COMPONENT_TEXT_NEW_VERSION, f, auth_chunks[1]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_ADDR, f, auth_chunks[2]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_PORT, f, auth_chunks[3]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_PROTOCOL, f, auth_chunks[4]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_USERNAME, f, auth_chunks[5]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_PASSWORD, f, auth_chunks[6]);
|
||||
self.view
|
||||
.render(super::COMPONENT_TEXT_FOOTER, f, auth_chunks[7]);
|
||||
// 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 {
|
||||
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 {
|
||||
// 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
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK)
|
||||
{
|
||||
if props.build().visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT)
|
||||
{
|
||||
if props.build().visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view
|
||||
.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 {
|
||||
// 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_BOOKMARK_SAVE_PWD)
|
||||
{
|
||||
if props.build().visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 20, 20);
|
||||
f.render_widget(Clear, popup);
|
||||
let popup_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(3), // Input form
|
||||
Constraint::Length(2), // Yes/No
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(popup);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_BOOKMARK_NAME, f, popup_chunks[0]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD, f, popup_chunks[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
self.context = Some(ctx);
|
||||
}
|
||||
|
||||
// -- partials
|
||||
|
||||
/// ### view_bookmarks
|
||||
///
|
||||
/// Make text span from bookmarks
|
||||
pub(super) fn view_bookmarks(&mut self) -> Option<(String, Msg)> {
|
||||
let bookmarks: Vec<TextSpan> = self
|
||||
.bookmarks_list
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let entry: (String, u16, FileTransferProtocol, String, _) = self
|
||||
.bookmarks_client
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_bookmark(x)
|
||||
.unwrap();
|
||||
TextSpan::from(
|
||||
format!(
|
||||
"{} ({}://{}@{}:{})",
|
||||
x,
|
||||
entry.2.to_string().to_lowercase(),
|
||||
entry.3,
|
||||
entry.0,
|
||||
entry.1
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
match self
|
||||
.view
|
||||
.get_props(super::COMPONENT_BOOKMARKS_LIST)
|
||||
.as_mut()
|
||||
{
|
||||
None => None,
|
||||
Some(props) => {
|
||||
let msg = self.view.update(
|
||||
super::COMPONENT_BOOKMARKS_LIST,
|
||||
props
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Bookmarks")),
|
||||
Some(bookmarks),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### view_recent_connections
|
||||
///
|
||||
/// View recent connections
|
||||
pub(super) fn view_recent_connections(&mut self) -> Option<(String, Msg)> {
|
||||
let bookmarks: Vec<TextSpan> = self
|
||||
.recents_list
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let entry: (String, u16, FileTransferProtocol, String) = self
|
||||
.bookmarks_client
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.get_recent(x)
|
||||
.unwrap();
|
||||
TextSpan::from(
|
||||
format!(
|
||||
"{}://{}@{}:{}",
|
||||
entry.2.to_string().to_lowercase(),
|
||||
entry.3,
|
||||
entry.0,
|
||||
entry.1
|
||||
)
|
||||
.as_str(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
match self.view.get_props(super::COMPONENT_RECENTS_LIST).as_mut() {
|
||||
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),
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- mount
|
||||
|
||||
/// ### mount_error
|
||||
///
|
||||
/// Mount error box
|
||||
pub(super) fn mount_error(&mut self, text: &str) {
|
||||
// Mount
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_ERROR,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to error
|
||||
self.view.active(super::COMPONENT_TEXT_ERROR);
|
||||
}
|
||||
|
||||
/// ### umount_error
|
||||
///
|
||||
/// Umount error message
|
||||
pub(super) fn umount_error(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_ERROR);
|
||||
}
|
||||
|
||||
/// ### mount_quit
|
||||
///
|
||||
/// Mount quit popup
|
||||
pub(super) fn mount_quit(&mut self) {
|
||||
// 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")]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_RADIO_QUIT);
|
||||
}
|
||||
|
||||
/// ### umount_quit
|
||||
///
|
||||
/// Umount quit popup
|
||||
pub(super) fn umount_quit(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_QUIT);
|
||||
}
|
||||
|
||||
/// ### mount_bookmark_del_dialog
|
||||
///
|
||||
/// Mount bookmark delete dialog
|
||||
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(
|
||||
Some(String::from("Delete bookmark?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active
|
||||
self.view
|
||||
.active(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK);
|
||||
}
|
||||
|
||||
/// ### umount_bookmark_del_dialog
|
||||
///
|
||||
/// umount delete bookmark dialog
|
||||
pub(super) fn umount_bookmark_del_dialog(&mut self) {
|
||||
self.view
|
||||
.umount(super::COMPONENT_RADIO_BOOKMARK_DEL_BOOKMARK);
|
||||
}
|
||||
|
||||
/// ### mount_bookmark_del_dialog
|
||||
///
|
||||
/// Mount recent delete dialog
|
||||
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(
|
||||
Some(String::from("Delete bookmark?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active
|
||||
self.view.active(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT);
|
||||
}
|
||||
|
||||
/// ### umount_recent_del_dialog
|
||||
///
|
||||
/// umount delete recent dialog
|
||||
pub(super) fn umount_recent_del_dialog(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_BOOKMARK_DEL_RECENT);
|
||||
}
|
||||
|
||||
/// ### mount_bookmark_save_dialog
|
||||
///
|
||||
/// Mount bookmark save dialog
|
||||
pub(super) fn mount_bookmark_save_dialog(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_BOOKMARK_NAME,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightCyan)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Save bookmark as...")),
|
||||
None,
|
||||
))
|
||||
//.with_borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
|
||||
.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(
|
||||
Some(String::from("Save password?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
//.with_value(PropValue::Unsigned(1))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to input bookmark name
|
||||
self.view.active(super::COMPONENT_INPUT_BOOKMARK_NAME);
|
||||
}
|
||||
|
||||
/// ### umount_bookmark_save_dialog
|
||||
///
|
||||
/// Umount bookmark save dialog
|
||||
pub(super) fn umount_bookmark_save_dialog(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_BOOKMARK_SAVE_PWD);
|
||||
self.view.umount(super::COMPONENT_INPUT_BOOKMARK_NAME);
|
||||
}
|
||||
|
||||
/// ### mount_help
|
||||
///
|
||||
/// Mount help
|
||||
pub(super) fn mount_help(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_HELP,
|
||||
Box::new(Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
Some(String::from("Help")),
|
||||
TableBuilder::default()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<ESC>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Quit TermSCP"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<TAB>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Switch from form and bookmarks"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<RIGHT/LEFT>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Switch bookmark tab"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<UP/DOWN>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Move up/down in current tab"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<ENTER>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Connect/Load bookmark"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<DEL|E>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Delete selected bookmark"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+C>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Enter setup"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+S>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Save bookmark"))
|
||||
.build(),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active help
|
||||
self.view.active(super::COMPONENT_TEXT_HELP);
|
||||
}
|
||||
|
||||
/// ### umount_help
|
||||
///
|
||||
/// Umount help
|
||||
pub(super) fn umount_help(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_HELP);
|
||||
}
|
||||
|
||||
/// ### get_input
|
||||
///
|
||||
/// 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(),
|
||||
};
|
||||
(addr, port, protocol, username, password)
|
||||
}
|
||||
}
|
||||
640
src/ui/activities/filetransfer_activity/actions.rs
Normal file
@@ -0,0 +1,640 @@
|
||||
//! ## 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
use super::{FileExplorerTab, FileTransferActivity, FsEntry, InputMode, LogLevel, PopupType};
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tui::style::Color;
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### callback_nothing_to_do
|
||||
///
|
||||
/// Self titled
|
||||
pub(super) fn callback_nothing_to_do(&mut self) {}
|
||||
|
||||
/// ### callback_change_directory
|
||||
///
|
||||
/// Callback for GOTO command
|
||||
pub(super) fn callback_change_directory(&mut self, input: String) {
|
||||
let dir_path: PathBuf = PathBuf::from(input);
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => {
|
||||
// If path is relative, concat pwd
|
||||
let abs_dir_path: PathBuf = match dir_path.is_relative() {
|
||||
true => {
|
||||
let mut d: PathBuf = self.context.as_ref().unwrap().local.pwd();
|
||||
d.push(dir_path);
|
||||
d
|
||||
}
|
||||
false => dir_path,
|
||||
};
|
||||
self.local_changedir(abs_dir_path.as_path(), true);
|
||||
}
|
||||
FileExplorerTab::Remote => {
|
||||
// If path is relative, concat pwd
|
||||
let abs_dir_path: PathBuf = match dir_path.is_relative() {
|
||||
true => match self.client.pwd() {
|
||||
Ok(mut wkrdir) => {
|
||||
wkrdir.push(dir_path);
|
||||
wkrdir
|
||||
}
|
||||
Err(err) => {
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not retrieve current directory: {}", err),
|
||||
));
|
||||
return;
|
||||
}
|
||||
},
|
||||
false => dir_path,
|
||||
};
|
||||
self.remote_changedir(abs_dir_path.as_path(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### callback_mkdir
|
||||
///
|
||||
/// Callback for MKDIR command (supports both local and remote)
|
||||
pub(super) fn callback_mkdir(&mut self, input: String) {
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => {
|
||||
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.context.as_ref().unwrap().local.pwd();
|
||||
self.local_scan(wrkdir.as_path());
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!("Could not create directory \"{}\": {}", input, err).as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not create directory \"{}\": {}", input, err),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
FileExplorerTab::Remote => {
|
||||
match self.client.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(
|
||||
LogLevel::Error,
|
||||
format!("Could not create directory \"{}\": {}", input, err).as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not create directory \"{}\": {}", input, err),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### callback_rename
|
||||
///
|
||||
/// Callback for RENAME command (supports borth local and remote)
|
||||
pub(super) fn callback_rename(&mut self, input: String) {
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => {
|
||||
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.context.as_ref().unwrap().local.pwd();
|
||||
wrkdir.push(dst_path);
|
||||
dst_path = wrkdir;
|
||||
}
|
||||
// Check if file entry exists
|
||||
if let Some(entry) = self.local.files.get(self.local.index) {
|
||||
let full_path: PathBuf = match entry {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
};
|
||||
// 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
|
||||
self.local_scan(self.context.as_ref().unwrap().local.pwd().as_path());
|
||||
// Log
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!(
|
||||
"Renamed file \"{}\" to \"{}\"",
|
||||
full_path.display(),
|
||||
dst_path.display()
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not rename file \"{}\": {}",
|
||||
full_path.display(),
|
||||
err
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not rename file: {}", err),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FileExplorerTab::Remote => {
|
||||
// Check if file entry exists
|
||||
if let Some(entry) = self.remote.files.get(self.remote.index) {
|
||||
let full_path: PathBuf = match entry {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
};
|
||||
// Rename file or directory and report status as popup
|
||||
let dst_path: PathBuf = PathBuf::from(input);
|
||||
match self.client.rename(entry, dst_path.as_path()) {
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
if let Ok(path) = self.client.pwd() {
|
||||
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(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not rename file \"{}\": {}",
|
||||
full_path.display(),
|
||||
err
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not rename file: {}", err),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### callback_delete_fsentry
|
||||
///
|
||||
/// Delete current selected fsentry in the currently selected TAB
|
||||
pub(super) fn callback_delete_fsentry(&mut self) {
|
||||
// Match current selected tab
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => {
|
||||
// Check if file entry exists
|
||||
if let Some(entry) = self.local.files.get(self.local.index) {
|
||||
let full_path: PathBuf = match entry {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
};
|
||||
// Delete file or directory and report status as popup
|
||||
match self.context.as_mut().unwrap().local.remove(entry) {
|
||||
Ok(_) => {
|
||||
// Reload files
|
||||
self.local_scan(self.context.as_ref().unwrap().local.pwd().as_path());
|
||||
// Log
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("Removed file \"{}\"", full_path.display()).as_ref(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not delete file \"{}\": {}",
|
||||
full_path.display(),
|
||||
err
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not delete file: {}", err),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FileExplorerTab::Remote => {
|
||||
// Check if file entry exists
|
||||
if let Some(entry) = self.remote.files.get(self.remote.index) {
|
||||
let full_path: PathBuf = match entry {
|
||||
FsEntry::Directory(dir) => dir.abs_path.clone(),
|
||||
FsEntry::File(file) => file.abs_path.clone(),
|
||||
};
|
||||
// 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(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Could not delete file \"{}\": {}",
|
||||
full_path.display(),
|
||||
err
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not delete file: {}", err),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### callback_save_as
|
||||
///
|
||||
/// Call file upload, but save with input as name
|
||||
/// Handled both local and remote tab
|
||||
pub(super) fn callback_save_as(&mut self, input: String) {
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => {
|
||||
// Get pwd
|
||||
let wrkdir: PathBuf = match self.client.pwd() {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!("Could not get current remote path: {}", err).as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not get current remote path: {}", err),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let files: Vec<FsEntry> = self.local.files.clone();
|
||||
// Get file at index
|
||||
if let Some(entry) = files.get(self.local.index) {
|
||||
// Call send (upload)
|
||||
self.filetransfer_send(entry, wrkdir.as_path(), Some(input));
|
||||
}
|
||||
}
|
||||
FileExplorerTab::Remote => {
|
||||
let files: Vec<FsEntry> = self.remote.files.clone();
|
||||
// Get file at index
|
||||
if let Some(entry) = files.get(self.remote.index) {
|
||||
// Call receive (download)
|
||||
self.filetransfer_recv(
|
||||
entry,
|
||||
self.context.as_ref().unwrap().local.pwd().as_path(),
|
||||
Some(input),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
use super::{
|
||||
DialogCallback, DialogYesNoOption, FileExplorerTab, FileTransferActivity, FsEntry, InputEvent,
|
||||
InputField, InputMode, LogLevel, OnInputSubmitCallback, PopupType,
|
||||
};
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use std::path::PathBuf;
|
||||
use tui::style::Color;
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### handle_input_event
|
||||
///
|
||||
/// Handle input event based on current input mode
|
||||
pub(super) fn handle_input_event(&mut self, ev: &InputEvent) {
|
||||
// NOTE: this is necessary due to this <https://github.com/rust-lang/rust/issues/59159>
|
||||
// NOTE: Do you want my opinion about that issue? It's a bs and doesn't make any sense.
|
||||
let popup: Option<PopupType> = match &self.input_mode {
|
||||
InputMode::Popup(ptype) => Some(ptype.clone()),
|
||||
_ => None,
|
||||
};
|
||||
match &self.input_mode {
|
||||
InputMode::Explorer => self.handle_input_event_mode_explorer(ev),
|
||||
InputMode::Popup(_) => {
|
||||
if let Some(popup) = popup {
|
||||
self.handle_input_event_mode_popup(ev, popup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer
|
||||
///
|
||||
/// Input event handler for explorer mode
|
||||
pub(super) fn handle_input_event_mode_explorer(&mut self, ev: &InputEvent) {
|
||||
// Match input field
|
||||
match self.input_field {
|
||||
InputField::Explorer => match self.tab {
|
||||
// Match current selected tab
|
||||
FileExplorerTab::Local => self.handle_input_event_mode_explorer_tab_local(ev),
|
||||
FileExplorerTab::Remote => self.handle_input_event_mode_explorer_tab_remote(ev),
|
||||
},
|
||||
InputField::Logs => self.handle_input_event_mode_explorer_log(ev),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer_tab_local
|
||||
///
|
||||
/// Input event handler for explorer mode when localhost tab is selected
|
||||
pub(super) fn handle_input_event_mode_explorer_tab_local(&mut self, ev: &InputEvent) {
|
||||
// Match events
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Handle quit event
|
||||
// Create quit prompt dialog
|
||||
self.input_mode = self.create_disconnect_popup();
|
||||
}
|
||||
KeyCode::Tab => self.switch_input_field(), // <TAB> switch tab
|
||||
KeyCode::Right => self.tab = FileExplorerTab::Remote, // <RIGHT> switch to right tab
|
||||
KeyCode::Up => {
|
||||
// Move index up
|
||||
if self.local.index > 0 {
|
||||
self.local.index -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Move index down
|
||||
if self.local.index + 1 < self.local.files.len() {
|
||||
self.local.index += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
// Move index up (fast)
|
||||
if self.local.index > 8 {
|
||||
self.local.index = self.local.index - 8; // Decrease by `8` if possible
|
||||
} else {
|
||||
self.local.index = 0; // Set to 0 otherwise
|
||||
}
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
// Move index down (fast)
|
||||
if self.local.index + 8 >= self.local.files.len() {
|
||||
// If overflows, set to size
|
||||
self.local.index = self.local.files.len() - 1;
|
||||
} else {
|
||||
self.local.index = self.local.index + 8; // Increase by `8`
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Match selected file
|
||||
let local_files: Vec<FsEntry> = self.local.files.clone();
|
||||
if let Some(entry) = local_files.get(self.local.index) {
|
||||
// If directory, enter directory, otherwise check if symlink
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.local_changedir(dir.abs_path.as_path(), true)
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
// Check if symlink
|
||||
if let Some(realpath) = &file.symlink {
|
||||
// Stat realpath
|
||||
match self
|
||||
.context
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.local
|
||||
.stat(realpath.as_path())
|
||||
{
|
||||
Ok(real_file) => {
|
||||
// If real file is a directory, enter directory
|
||||
if let FsEntry::Directory(real_dir) = real_file {
|
||||
self.local_changedir(
|
||||
real_dir.abs_path.as_path(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Failed to stat file \"{}\": {}",
|
||||
realpath.display(),
|
||||
err
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.input_mode =
|
||||
InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!(
|
||||
"Failed to stat file \"{}\": {}",
|
||||
realpath.display(),
|
||||
err
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
// Go to previous directory
|
||||
if let Some(d) = self.local.popd() {
|
||||
self.local_changedir(d.as_path(), false);
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
// Get file at index
|
||||
if let Some(entry) = self.local.files.get(self.local.index) {
|
||||
// Get file name
|
||||
let file_name: String = match entry {
|
||||
FsEntry::Directory(dir) => dir.name.clone(),
|
||||
FsEntry::File(file) => file.name.clone(),
|
||||
};
|
||||
// Show delete prompt
|
||||
self.input_mode = InputMode::Popup(PopupType::YesNo(
|
||||
format!("Delete file \"{}\"", file_name),
|
||||
FileTransferActivity::callback_delete_fsentry,
|
||||
FileTransferActivity::callback_nothing_to_do,
|
||||
))
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) => match ch {
|
||||
'q' | 'Q' => {
|
||||
// Create quit prompt dialog
|
||||
self.input_mode = self.create_quit_popup();
|
||||
}
|
||||
'g' | 'G' => {
|
||||
// Goto
|
||||
// Show input popup
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Change working directory"),
|
||||
FileTransferActivity::callback_change_directory,
|
||||
));
|
||||
}
|
||||
'd' | 'D' => {
|
||||
// Make directory
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Insert directory name"),
|
||||
FileTransferActivity::callback_mkdir,
|
||||
));
|
||||
}
|
||||
'h' | 'H' => {
|
||||
// Show help
|
||||
self.input_mode = InputMode::Popup(PopupType::Help);
|
||||
}
|
||||
'i' | 'I' => {
|
||||
// Show file info
|
||||
self.input_mode = InputMode::Popup(PopupType::FileInfo);
|
||||
}
|
||||
'r' | 'R' => {
|
||||
// Rename
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Insert new name"),
|
||||
FileTransferActivity::callback_rename,
|
||||
));
|
||||
}
|
||||
's' | 'S' => {
|
||||
// Save as...
|
||||
// Ask for input
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Save as..."),
|
||||
FileTransferActivity::callback_save_as,
|
||||
));
|
||||
}
|
||||
'u' | 'U' => {
|
||||
// Go to parent directory
|
||||
// Get pwd
|
||||
let path: PathBuf = self.context.as_ref().unwrap().local.pwd();
|
||||
if let Some(parent) = path.as_path().parent() {
|
||||
self.local_changedir(parent, true);
|
||||
}
|
||||
}
|
||||
' ' => {
|
||||
// Get pwd
|
||||
let wrkdir: PathBuf = match self.client.pwd() {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!("Could not get current remote path: {}", err)
|
||||
.as_ref(),
|
||||
);
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not get current remote path: {}", err),
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Get files
|
||||
let files: Vec<FsEntry> = self.local.files.clone(); // Otherwise self is borrowed both as mutable and immutable...
|
||||
// Get file at index
|
||||
if let Some(entry) = files.get(self.local.index) {
|
||||
// Call upload
|
||||
self.filetransfer_send(entry, wrkdir.as_path(), None);
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
},
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer_tab_local
|
||||
///
|
||||
/// Input event handler for explorer mode when remote tab is selected
|
||||
pub(super) fn handle_input_event_mode_explorer_tab_remote(&mut self, ev: &InputEvent) {
|
||||
// Match events
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Handle quit event
|
||||
// Create quit prompt dialog
|
||||
self.input_mode = self.create_disconnect_popup();
|
||||
}
|
||||
KeyCode::Tab => self.switch_input_field(), // <TAB> switch tab
|
||||
KeyCode::Left => self.tab = FileExplorerTab::Local, // <LEFT> switch to local tab
|
||||
KeyCode::Up => {
|
||||
// Move index up
|
||||
if self.remote.index > 0 {
|
||||
self.remote.index -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Move index down
|
||||
if self.remote.index + 1 < self.remote.files.len() {
|
||||
self.remote.index += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
// Move index up (fast)
|
||||
if self.remote.index > 8 {
|
||||
self.remote.index = self.remote.index - 8; // Decrease by `8` if possible
|
||||
} else {
|
||||
self.remote.index = 0; // Set to 0 otherwise
|
||||
}
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
// Move index down (fast)
|
||||
if self.remote.index + 8 >= self.remote.files.len() {
|
||||
// If overflows, set to size
|
||||
self.remote.index = self.remote.files.len() - 1;
|
||||
} else {
|
||||
self.remote.index = self.remote.index + 8; // Increase by `8`
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Match selected file
|
||||
let files: Vec<FsEntry> = self.remote.files.clone();
|
||||
if let Some(entry) = files.get(self.remote.index) {
|
||||
// If directory, enter directory; if file, check if is symlink
|
||||
match entry {
|
||||
FsEntry::Directory(dir) => {
|
||||
self.remote_changedir(dir.abs_path.as_path(), true)
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
// Check if symlink
|
||||
if let Some(realpath) = &file.symlink {
|
||||
// Stat realpath
|
||||
match self.client.stat(realpath.as_path()) {
|
||||
Ok(real_file) => {
|
||||
// If real file is a directory, enter directory
|
||||
if let FsEntry::Directory(real_dir) = real_file {
|
||||
self.remote_changedir(
|
||||
real_dir.abs_path.as_path(),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!(
|
||||
"Failed to stat file \"{}\": {}",
|
||||
realpath.display(),
|
||||
err
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.input_mode =
|
||||
InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!(
|
||||
"Failed to stat file \"{}\": {}",
|
||||
realpath.display(),
|
||||
err
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
// Go to previous directory
|
||||
if let Some(d) = self.remote.popd() {
|
||||
self.remote_changedir(d.as_path(), false);
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
// Get file at index
|
||||
if let Some(entry) = self.remote.files.get(self.remote.index) {
|
||||
// Get file name
|
||||
let file_name: String = match entry {
|
||||
FsEntry::Directory(dir) => dir.name.clone(),
|
||||
FsEntry::File(file) => file.name.clone(),
|
||||
};
|
||||
// Show delete prompt
|
||||
self.input_mode = InputMode::Popup(PopupType::YesNo(
|
||||
format!("Delete file \"{}\"", file_name),
|
||||
FileTransferActivity::callback_delete_fsentry,
|
||||
FileTransferActivity::callback_nothing_to_do,
|
||||
))
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) => match ch {
|
||||
'q' | 'Q' => {
|
||||
// Create quit prompt dialog
|
||||
self.input_mode = self.create_quit_popup();
|
||||
}
|
||||
'g' | 'G' => {
|
||||
// Goto
|
||||
// Show input popup
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Change working directory"),
|
||||
FileTransferActivity::callback_change_directory,
|
||||
));
|
||||
}
|
||||
'd' | 'D' => {
|
||||
// Make directory
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Insert directory name"),
|
||||
FileTransferActivity::callback_mkdir,
|
||||
));
|
||||
}
|
||||
'h' | 'H' => {
|
||||
// Show help
|
||||
self.input_mode = InputMode::Popup(PopupType::Help);
|
||||
}
|
||||
'i' | 'I' => {
|
||||
// Show file info
|
||||
self.input_mode = InputMode::Popup(PopupType::FileInfo);
|
||||
}
|
||||
'r' | 'R' => {
|
||||
// Rename
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Insert new name"),
|
||||
FileTransferActivity::callback_rename,
|
||||
));
|
||||
}
|
||||
's' | 'S' => {
|
||||
// Save as...
|
||||
// Ask for input
|
||||
self.input_mode = InputMode::Popup(PopupType::Input(
|
||||
String::from("Save as..."),
|
||||
FileTransferActivity::callback_save_as,
|
||||
));
|
||||
}
|
||||
'u' | 'U' => {
|
||||
// Go to parent directory
|
||||
// Get pwd
|
||||
match self.client.pwd() {
|
||||
Ok(path) => {
|
||||
if let Some(parent) = path.as_path().parent() {
|
||||
self.remote_changedir(parent, true);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.input_mode = InputMode::Popup(PopupType::Alert(
|
||||
Color::Red,
|
||||
format!("Could not change working directory: {}", err),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
' ' => {
|
||||
// Get files
|
||||
let files: Vec<FsEntry> = self.remote.files.clone(); // Otherwise self is borrowed both as mutable and immutable...
|
||||
// Get file at index
|
||||
if let Some(entry) = files.get(self.remote.index) {
|
||||
// Call upload
|
||||
self.filetransfer_recv(
|
||||
entry,
|
||||
self.context.as_ref().unwrap().local.pwd().as_path(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
},
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer_log
|
||||
///
|
||||
/// Input even handler for explorer mode when log tab is selected
|
||||
pub(super) fn handle_input_event_mode_explorer_log(&mut self, ev: &InputEvent) {
|
||||
// Match event
|
||||
let records_block: usize = 16;
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Handle quit event
|
||||
// Create quit prompt dialog
|
||||
self.input_mode = self.create_disconnect_popup();
|
||||
}
|
||||
KeyCode::Tab => self.switch_input_field(), // <TAB> switch tab
|
||||
KeyCode::Down => {
|
||||
// NOTE: Twisted logic
|
||||
// Decrease log index
|
||||
if self.log_index > 0 {
|
||||
self.log_index = self.log_index - 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// NOTE: Twisted logic
|
||||
// Increase log index
|
||||
if self.log_index + 1 < self.log_records.len() {
|
||||
self.log_index = self.log_index + 1;
|
||||
}
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
// NOTE: Twisted logic
|
||||
// Fast decreasing of log index
|
||||
if self.log_index >= records_block {
|
||||
self.log_index = self.log_index - records_block; // Decrease by `records_block` if possible
|
||||
} else {
|
||||
self.log_index = 0; // Set to 0 otherwise
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
// NOTE: Twisted logic
|
||||
// Fast increasing of log index
|
||||
if self.log_index + records_block >= self.log_records.len() {
|
||||
// If overflows, set to size
|
||||
self.log_index = self.log_records.len() - 1;
|
||||
} else {
|
||||
self.log_index = self.log_index + records_block; // Increase by `records_block`
|
||||
}
|
||||
}
|
||||
KeyCode::Char(ch) => match ch {
|
||||
'q' | 'Q' => {
|
||||
// Create quit prompt dialog
|
||||
self.input_mode = self.create_quit_popup();
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
},
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer
|
||||
///
|
||||
/// Input event handler for popup mode. Handler is then based on Popup type
|
||||
pub(super) fn handle_input_event_mode_popup(&mut self, ev: &InputEvent, popup: PopupType) {
|
||||
match popup {
|
||||
PopupType::Alert(_, _) => self.handle_input_event_mode_popup_alert(ev),
|
||||
PopupType::FileInfo => self.handle_input_event_mode_popup_fileinfo(ev),
|
||||
PopupType::Help => self.handle_input_event_mode_popup_help(ev),
|
||||
PopupType::Fatal(_) => self.handle_input_event_mode_popup_fatal(ev),
|
||||
PopupType::Input(_, cb) => self.handle_input_event_mode_popup_input(ev, cb),
|
||||
PopupType::Progress(_) => self.handle_input_event_mode_popup_progress(ev),
|
||||
PopupType::Wait(_) => self.handle_input_event_mode_popup_wait(ev),
|
||||
PopupType::YesNo(_, yes_cb, no_cb) => {
|
||||
self.handle_input_event_mode_popup_yesno(ev, yes_cb, no_cb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_popup_alert
|
||||
///
|
||||
/// Input event handler for popup alert
|
||||
pub(super) fn handle_input_event_mode_popup_alert(&mut self, ev: &InputEvent) {
|
||||
// If enter, close popup
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
// Set input mode back to explorer
|
||||
self.input_mode = InputMode::Explorer;
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_popup_fileinfo
|
||||
///
|
||||
/// Input event handler for popup fileinfo
|
||||
pub(super) fn handle_input_event_mode_popup_fileinfo(&mut self, ev: &InputEvent) {
|
||||
// If enter, close popup
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Esc => {
|
||||
// Set input mode back to explorer
|
||||
self.input_mode = InputMode::Explorer;
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_popup_help
|
||||
///
|
||||
/// Input event handler for popup help
|
||||
pub(super) fn handle_input_event_mode_popup_help(&mut self, ev: &InputEvent) {
|
||||
// If enter, close popup
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Esc => {
|
||||
// Set input mode back to explorer
|
||||
self.input_mode = InputMode::Explorer;
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_popup_fatal
|
||||
///
|
||||
/// Input event handler for popup alert
|
||||
pub(super) fn handle_input_event_mode_popup_fatal(&mut self, ev: &InputEvent) {
|
||||
// If enter, close popup
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
// Set quit to true; since a fatal error happened
|
||||
self.disconnect();
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_popup_input
|
||||
///
|
||||
/// Input event handler for input popup
|
||||
pub(super) fn handle_input_event_mode_popup_input(
|
||||
&mut self,
|
||||
ev: &InputEvent,
|
||||
cb: OnInputSubmitCallback,
|
||||
) {
|
||||
// If enter, close popup, otherwise push chars to input
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
// Abort input
|
||||
// Clear current input text
|
||||
self.input_txt.clear();
|
||||
// Set mode back to explorer
|
||||
self.input_mode = InputMode::Explorer;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Submit
|
||||
let input_text: String = self.input_txt.clone();
|
||||
// Clear current input text
|
||||
self.input_txt.clear();
|
||||
// Set mode back to explorer BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
|
||||
self.input_mode = InputMode::Explorer;
|
||||
// Call cb
|
||||
cb(self, input_text);
|
||||
}
|
||||
KeyCode::Char(ch) => self.input_txt.push(ch),
|
||||
KeyCode::Backspace => {
|
||||
let _ = self.input_txt.pop();
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer_alert
|
||||
///
|
||||
/// Input event handler for popup alert
|
||||
pub(super) fn handle_input_event_mode_popup_progress(&mut self, ev: &InputEvent) {
|
||||
// There's nothing you can do here I guess... maybe ctrl+c in the future idk
|
||||
match ev {
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer_alert
|
||||
///
|
||||
/// Input event handler for popup alert
|
||||
pub(super) fn handle_input_event_mode_popup_wait(&mut self, ev: &InputEvent) {
|
||||
// There's nothing you can do here I guess... maybe ctrl+c in the future idk
|
||||
match ev {
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// ### handle_input_event_mode_explorer_alert
|
||||
///
|
||||
/// Input event handler for popup alert
|
||||
pub(super) fn handle_input_event_mode_popup_yesno(
|
||||
&mut self,
|
||||
ev: &InputEvent,
|
||||
yes_cb: DialogCallback,
|
||||
no_cb: DialogCallback,
|
||||
) {
|
||||
// If enter, close popup, otherwise move dialog option
|
||||
match ev {
|
||||
InputEvent::Key(key) => {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
// @! Set input mode to Explorer BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
|
||||
self.input_mode = InputMode::Explorer;
|
||||
// Check if user selected yes or not
|
||||
match self.choice_opt {
|
||||
DialogYesNoOption::No => no_cb(self),
|
||||
DialogYesNoOption::Yes => yes_cb(self),
|
||||
}
|
||||
// Reset choice option to yes
|
||||
self.choice_opt = DialogYesNoOption::Yes;
|
||||
}
|
||||
KeyCode::Right => self.choice_opt = DialogYesNoOption::No, // Set to NO
|
||||
KeyCode::Left => self.choice_opt = DialogYesNoOption::Yes, // Set to YES
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
_ => { /* Nothing to do */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,891 +0,0 @@
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
extern crate bytesize;
|
||||
extern crate hostname;
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
extern crate users;
|
||||
|
||||
use super::{
|
||||
Context, DialogYesNoOption, FileExplorerTab, FileTransferActivity, FsEntry, InputField,
|
||||
InputMode, LogLevel, LogRecord, PopupType,
|
||||
};
|
||||
use crate::utils::time_to_str;
|
||||
|
||||
use bytesize::ByteSize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tui::{
|
||||
layout::{Constraint, Corner, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Tabs},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
use users::{get_group_by_gid, get_user_by_uid};
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### draw
|
||||
///
|
||||
/// Draw UI
|
||||
pub(super) fn draw(&mut self) {
|
||||
let mut ctx: Context = self.context.take().unwrap();
|
||||
let local_wrkdir: PathBuf = ctx.local.pwd();
|
||||
let _ = ctx.terminal.draw(|f| {
|
||||
// Prepare chunks
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(2)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(20), // Explorer
|
||||
Constraint::Length(16), // Log
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(f.size());
|
||||
// Create explorer chunks
|
||||
let tabs_chunks = Layout::default()
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||
.direction(Direction::Horizontal)
|
||||
.split(chunks[0]);
|
||||
// Set localhost state
|
||||
let mut localhost_state: ListState = ListState::default();
|
||||
localhost_state.select(Some(self.local.index));
|
||||
// Set remote state
|
||||
let mut remote_state: ListState = ListState::default();
|
||||
remote_state.select(Some(self.remote.index));
|
||||
// Draw tabs
|
||||
f.render_stateful_widget(
|
||||
self.draw_local_explorer(local_wrkdir, tabs_chunks[0].width),
|
||||
tabs_chunks[0],
|
||||
&mut localhost_state,
|
||||
);
|
||||
// Get pwd
|
||||
let remote_wrkdir: PathBuf = match self.client.pwd() {
|
||||
Ok(p) => p,
|
||||
Err(_) => PathBuf::from("/"),
|
||||
};
|
||||
f.render_stateful_widget(
|
||||
self.draw_remote_explorer(remote_wrkdir, tabs_chunks[1].width),
|
||||
tabs_chunks[1],
|
||||
&mut remote_state,
|
||||
);
|
||||
// Set log state
|
||||
let mut log_state: ListState = ListState::default();
|
||||
log_state.select(Some(self.log_index));
|
||||
// Draw log
|
||||
f.render_stateful_widget(
|
||||
self.draw_log_list(chunks[1].width),
|
||||
chunks[1],
|
||||
&mut log_state,
|
||||
);
|
||||
// Draw popup
|
||||
if let InputMode::Popup(popup) = &self.input_mode {
|
||||
// Calculate popup size
|
||||
let (width, height): (u16, u16) = match popup {
|
||||
PopupType::Alert(_, _) => (50, 10),
|
||||
PopupType::Fatal(_) => (50, 10),
|
||||
PopupType::FileInfo => (50, 50),
|
||||
PopupType::Help => (50, 70),
|
||||
PopupType::Input(_, _) => (40, 10),
|
||||
PopupType::Progress(_) => (40, 10),
|
||||
PopupType::Wait(_) => (50, 10),
|
||||
PopupType::YesNo(_, _, _) => (30, 10),
|
||||
};
|
||||
let popup_area: Rect = self.draw_popup_area(f.size(), width, height);
|
||||
f.render_widget(Clear, popup_area); //this clears out the background
|
||||
match popup {
|
||||
PopupType::Alert(color, txt) => f.render_widget(
|
||||
self.draw_popup_alert(color.clone(), txt.clone(), popup_area.width),
|
||||
popup_area,
|
||||
),
|
||||
PopupType::Fatal(txt) => f.render_widget(
|
||||
self.draw_popup_fatal(txt.clone(), popup_area.width),
|
||||
popup_area,
|
||||
),
|
||||
PopupType::FileInfo => f.render_widget(self.draw_popup_fileinfo(), popup_area),
|
||||
PopupType::Help => f.render_widget(self.draw_popup_help(), popup_area),
|
||||
PopupType::Input(txt, _) => {
|
||||
f.render_widget(self.draw_popup_input(txt.clone()), popup_area);
|
||||
// Set cursor
|
||||
f.set_cursor(
|
||||
popup_area.x + self.input_txt.width() as u16 + 1,
|
||||
popup_area.y + 1,
|
||||
)
|
||||
}
|
||||
PopupType::Progress(txt) => {
|
||||
f.render_widget(self.draw_popup_progress(txt.clone()), popup_area)
|
||||
}
|
||||
PopupType::Wait(txt) => f.render_widget(
|
||||
self.draw_popup_wait(txt.clone(), popup_area.width),
|
||||
popup_area,
|
||||
),
|
||||
PopupType::YesNo(txt, _, _) => {
|
||||
f.render_widget(self.draw_popup_yesno(txt.clone()), popup_area)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.context = Some(ctx);
|
||||
}
|
||||
|
||||
/// ### draw_local_explorer
|
||||
///
|
||||
/// Draw local explorer list
|
||||
pub(super) fn draw_local_explorer(&self, local_wrkdir: PathBuf, width: u16) -> List {
|
||||
let hostname: String = match hostname::get() {
|
||||
Ok(h) => String::from(h.as_os_str().to_string_lossy()),
|
||||
Err(_) => String::from("localhost"),
|
||||
};
|
||||
let files: Vec<ListItem> = self
|
||||
.local
|
||||
.files
|
||||
.iter()
|
||||
.map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry))))
|
||||
.collect();
|
||||
List::new(files)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(match self.input_field {
|
||||
InputField::Explorer => match self.tab {
|
||||
FileExplorerTab::Local => Style::default().fg(Color::Yellow),
|
||||
_ => Style::default(),
|
||||
},
|
||||
_ => Style::default(),
|
||||
})
|
||||
.title(format!(
|
||||
"{}:{} ",
|
||||
hostname,
|
||||
FileTransferActivity::elide_wrkdir_path(
|
||||
local_wrkdir.as_path(),
|
||||
hostname.as_str(),
|
||||
width
|
||||
)
|
||||
.display()
|
||||
)),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
}
|
||||
|
||||
/// ### draw_remote_explorer
|
||||
///
|
||||
/// Draw remote explorer list
|
||||
pub(super) fn draw_remote_explorer(&self, remote_wrkdir: PathBuf, width: u16) -> List {
|
||||
let files: Vec<ListItem> = self
|
||||
.remote
|
||||
.files
|
||||
.iter()
|
||||
.map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry))))
|
||||
.collect();
|
||||
List::new(files)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(match self.input_field {
|
||||
InputField::Explorer => match self.tab {
|
||||
FileExplorerTab::Remote => Style::default().fg(Color::LightBlue),
|
||||
_ => Style::default(),
|
||||
},
|
||||
_ => Style::default(),
|
||||
})
|
||||
.title(format!(
|
||||
"{}:{} ",
|
||||
self.params.address,
|
||||
FileTransferActivity::elide_wrkdir_path(
|
||||
remote_wrkdir.as_path(),
|
||||
self.params.address.as_str(),
|
||||
width
|
||||
)
|
||||
.display()
|
||||
)),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::LightBlue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
}
|
||||
|
||||
/// ### draw_log_list
|
||||
///
|
||||
/// Draw log list
|
||||
/// Chunk width must be provided to wrap text
|
||||
pub(super) fn draw_log_list(&self, width: u16) -> List {
|
||||
let events: Vec<ListItem> = self
|
||||
.log_records
|
||||
.iter()
|
||||
.map(|record: &LogRecord| {
|
||||
let record_rows = textwrap::wrap(record.msg.as_str(), (width as usize) - 35); // -35 'cause log prefix
|
||||
let s = match record.level {
|
||||
LogLevel::Error => Style::default().fg(Color::Red),
|
||||
LogLevel::Warn => Style::default().fg(Color::Yellow),
|
||||
LogLevel::Info => Style::default().fg(Color::Green),
|
||||
};
|
||||
let mut rows: Vec<Spans> = Vec::with_capacity(record_rows.len());
|
||||
// Iterate over remaining rows
|
||||
for (idx, row) in record_rows.iter().enumerate() {
|
||||
let row: Spans = match idx {
|
||||
0 => Spans::from(vec![
|
||||
Span::from(format!("{}", record.time.format("%Y-%m-%dT%H:%M:%S%Z"))),
|
||||
Span::raw(" ["),
|
||||
Span::styled(
|
||||
format!(
|
||||
"{:5}",
|
||||
match record.level {
|
||||
LogLevel::Error => "ERROR",
|
||||
LogLevel::Warn => "WARN",
|
||||
LogLevel::Info => "INFO",
|
||||
}
|
||||
),
|
||||
s,
|
||||
),
|
||||
Span::raw("]: "),
|
||||
Span::from(String::from(row.as_ref())),
|
||||
]),
|
||||
_ => Spans::from(vec![Span::from(textwrap::indent(
|
||||
row.as_ref(),
|
||||
" ",
|
||||
))]),
|
||||
};
|
||||
rows.push(row);
|
||||
}
|
||||
ListItem::new(rows)
|
||||
})
|
||||
.collect();
|
||||
List::new(events)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(match self.input_field {
|
||||
InputField::Logs => Style::default().fg(Color::LightGreen),
|
||||
_ => Style::default(),
|
||||
})
|
||||
.title("Log"),
|
||||
)
|
||||
.start_corner(Corner::BottomLeft)
|
||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
|
||||
}
|
||||
|
||||
/// ### draw_popup_area
|
||||
///
|
||||
/// Draw popup area
|
||||
pub(super) fn draw_popup_area(&self, area: Rect, width: u16, height: u16) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - height) / 2),
|
||||
Constraint::Percentage(height),
|
||||
Constraint::Percentage((100 - height) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(area);
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - width) / 2),
|
||||
Constraint::Percentage(width),
|
||||
Constraint::Percentage((100 - width) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
|
||||
/// ### draw_popup_alert
|
||||
///
|
||||
/// Draw alert popup
|
||||
pub(super) fn draw_popup_alert(&self, color: Color, text: String, width: u16) -> List {
|
||||
// Wraps texts
|
||||
let message_rows = textwrap::wrap(text.as_str(), width as usize);
|
||||
let mut lines: Vec<ListItem> = Vec::new();
|
||||
for msg in message_rows.iter() {
|
||||
lines.push(ListItem::new(Spans::from(
|
||||
FileTransferActivity::align_text_center(msg, width),
|
||||
)));
|
||||
}
|
||||
List::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(color))
|
||||
.title("Alert"),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
.style(Style::default().fg(color))
|
||||
}
|
||||
|
||||
/// ### draw_popup_fatal
|
||||
///
|
||||
/// Draw fatal error popup
|
||||
pub(super) fn draw_popup_fatal(&self, text: String, width: u16) -> List {
|
||||
// Wraps texts
|
||||
let message_rows = textwrap::wrap(text.as_str(), width as usize);
|
||||
let mut lines: Vec<ListItem> = Vec::new();
|
||||
for msg in message_rows.iter() {
|
||||
lines.push(ListItem::new(Spans::from(
|
||||
FileTransferActivity::align_text_center(msg, width),
|
||||
)));
|
||||
}
|
||||
List::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Red))
|
||||
.title("Fatal error"),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
.style(Style::default().fg(Color::Red))
|
||||
}
|
||||
/// ### draw_popup_input
|
||||
///
|
||||
/// Draw input popup
|
||||
pub(super) fn draw_popup_input(&self, text: String) -> Paragraph {
|
||||
Paragraph::new(self.input_txt.as_ref())
|
||||
.style(Style::default().fg(Color::White))
|
||||
.block(Block::default().borders(Borders::ALL).title(text))
|
||||
}
|
||||
|
||||
/// ### draw_popup_progress
|
||||
///
|
||||
/// Draw progress popup
|
||||
pub(super) fn draw_popup_progress(&self, text: String) -> Gauge {
|
||||
// Calculate ETA
|
||||
let eta: String = match self.transfer_progress as u64 {
|
||||
0 => String::from("--:--"), // NOTE: would divide by 0 :D
|
||||
_ => {
|
||||
let elapsed_secs: u64 = self.transfer_started.elapsed().as_secs();
|
||||
let eta: u64 =
|
||||
((elapsed_secs * 100) / (self.transfer_progress as u64)) - elapsed_secs;
|
||||
format!("{:0width$}:{:0width$}", (eta / 60), (eta % 60), width = 2)
|
||||
}
|
||||
};
|
||||
let label = format!("{:.2}% - ETA {}", self.transfer_progress, eta);
|
||||
Gauge::default()
|
||||
.block(Block::default().borders(Borders::ALL).title(text))
|
||||
.gauge_style(
|
||||
Style::default()
|
||||
.fg(Color::Green)
|
||||
.bg(Color::Black)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.label(label)
|
||||
.ratio(self.transfer_progress / 100.0)
|
||||
}
|
||||
|
||||
/// ### draw_popup_wait
|
||||
///
|
||||
/// Draw wait popup
|
||||
pub(super) fn draw_popup_wait(&self, text: String, width: u16) -> List {
|
||||
// Wraps texts
|
||||
let message_rows = textwrap::wrap(text.as_str(), width as usize);
|
||||
let mut lines: Vec<ListItem> = Vec::new();
|
||||
for msg in message_rows.iter() {
|
||||
lines.push(ListItem::new(Spans::from(
|
||||
FileTransferActivity::align_text_center(msg, width),
|
||||
)));
|
||||
}
|
||||
List::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::White))
|
||||
.title("Please wait"),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
.style(Style::default().add_modifier(Modifier::BOLD))
|
||||
}
|
||||
|
||||
/// ### draw_popup_yesno
|
||||
///
|
||||
/// Draw yes/no select popup
|
||||
pub(super) fn draw_popup_yesno(&self, text: String) -> Tabs {
|
||||
let choices: Vec<Spans> = vec![Spans::from("Yes"), Spans::from("No")];
|
||||
let index: usize = match self.choice_opt {
|
||||
DialogYesNoOption::Yes => 0,
|
||||
DialogYesNoOption::No => 1,
|
||||
};
|
||||
Tabs::new(choices)
|
||||
.block(Block::default().borders(Borders::ALL).title(text))
|
||||
.select(index)
|
||||
.style(Style::default())
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.fg(Color::Yellow),
|
||||
)
|
||||
}
|
||||
|
||||
/// ### draw_popup_fileinfo
|
||||
///
|
||||
/// Draw popup containing info about selected fsentry
|
||||
pub(super) fn draw_popup_fileinfo(&self) -> List {
|
||||
let mut info: Vec<ListItem> = Vec::new();
|
||||
// Get current fsentry
|
||||
let fsentry: Option<&FsEntry> = match self.tab {
|
||||
FileExplorerTab::Local => {
|
||||
// Get selected file
|
||||
match self.local.files.get(self.local.index) {
|
||||
Some(entry) => Some(entry),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
FileExplorerTab::Remote => match self.remote.files.get(self.remote.index) {
|
||||
Some(entry) => Some(entry),
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
// Get file_name and fill info list
|
||||
let file_name: String = match fsentry {
|
||||
Some(fsentry) => match fsentry {
|
||||
FsEntry::Directory(dir) => {
|
||||
// Push path
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Path: ", Style::default()),
|
||||
Span::styled(
|
||||
match &dir.symlink {
|
||||
Some(symlink) => {
|
||||
format!("{} => {}", dir.abs_path.display(), symlink.display())
|
||||
}
|
||||
None => dir.abs_path.to_string_lossy().to_string(),
|
||||
},
|
||||
Style::default()
|
||||
.fg(Color::LightYellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Push creation time
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Creation time: ", Style::default()),
|
||||
Span::styled(
|
||||
time_to_str(dir.creation_time, "%b %d %Y %H:%M:%S"),
|
||||
Style::default()
|
||||
.fg(Color::LightGreen)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Push Last change
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Last change time: ", Style::default()),
|
||||
Span::styled(
|
||||
time_to_str(dir.last_change_time, "%b %d %Y %H:%M:%S"),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Push Last access
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Last access time: ", Style::default()),
|
||||
Span::styled(
|
||||
time_to_str(dir.last_access_time, "%b %d %Y %H:%M:%S"),
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// User
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
let username: String = match dir.user {
|
||||
Some(uid) => match get_user_by_uid(uid) {
|
||||
Some(user) => user.name().to_string_lossy().to_string(),
|
||||
None => uid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let username: String = format!("{}", dir.user.unwrap_or(0));
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("User: ", Style::default()),
|
||||
Span::styled(
|
||||
username,
|
||||
Style::default()
|
||||
.fg(Color::LightRed)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Group
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
let group: String = match dir.group {
|
||||
Some(gid) => match get_group_by_gid(gid) {
|
||||
Some(group) => group.name().to_string_lossy().to_string(),
|
||||
None => gid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let group: String = format!("{}", dir.group.unwrap_or(0));
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Group: ", Style::default()),
|
||||
Span::styled(
|
||||
group,
|
||||
Style::default()
|
||||
.fg(Color::LightBlue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Finally return file name
|
||||
dir.name.clone()
|
||||
}
|
||||
FsEntry::File(file) => {
|
||||
// Push path
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Path: ", Style::default()),
|
||||
Span::styled(
|
||||
match &file.symlink {
|
||||
Some(symlink) => {
|
||||
format!("{} => {}", file.abs_path.display(), symlink.display())
|
||||
}
|
||||
None => file.abs_path.to_string_lossy().to_string(),
|
||||
},
|
||||
Style::default()
|
||||
.fg(Color::LightYellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Push size
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Size: ", Style::default()),
|
||||
Span::styled(
|
||||
format!("{} ({})", ByteSize(file.size as u64), file.size),
|
||||
Style::default()
|
||||
.fg(Color::LightBlue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Push creation time
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Creation time: ", Style::default()),
|
||||
Span::styled(
|
||||
time_to_str(file.creation_time, "%b %d %Y %H:%M:%S"),
|
||||
Style::default()
|
||||
.fg(Color::LightGreen)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Push Last change
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Last change time: ", Style::default()),
|
||||
Span::styled(
|
||||
time_to_str(file.last_change_time, "%b %d %Y %H:%M:%S"),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Push Last access
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Last access time: ", Style::default()),
|
||||
Span::styled(
|
||||
time_to_str(file.last_access_time, "%b %d %Y %H:%M:%S"),
|
||||
Style::default()
|
||||
.fg(Color::LightMagenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// User
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
let username: String = match file.user {
|
||||
Some(uid) => match get_user_by_uid(uid) {
|
||||
Some(user) => user.name().to_string_lossy().to_string(),
|
||||
None => uid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let username: String = format!("{}", file.user.unwrap_or(0));
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("User: ", Style::default()),
|
||||
Span::styled(
|
||||
username,
|
||||
Style::default()
|
||||
.fg(Color::LightRed)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Group
|
||||
#[cfg(any(unix, macos, linux))]
|
||||
let group: String = match file.group {
|
||||
Some(gid) => match get_group_by_gid(gid) {
|
||||
Some(group) => group.name().to_string_lossy().to_string(),
|
||||
None => gid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let group: String = format!("{}", file.group.unwrap_or(0));
|
||||
info.push(ListItem::new(Spans::from(vec![
|
||||
Span::styled("Group: ", Style::default()),
|
||||
Span::styled(
|
||||
group,
|
||||
Style::default()
|
||||
.fg(Color::LightBlue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
])));
|
||||
// Finally return file name
|
||||
file.name.clone()
|
||||
}
|
||||
},
|
||||
None => String::from(""),
|
||||
};
|
||||
List::new(info)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default())
|
||||
.title(file_name),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
}
|
||||
|
||||
/// ### draw_footer
|
||||
///
|
||||
/// Draw authentication page footer
|
||||
pub(super) fn draw_popup_help(&self) -> List {
|
||||
// Write header
|
||||
let cmds: Vec<ListItem> = vec![
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<ESC>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("disconnect"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<TAB>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("Switch between log tab and explorer"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<BACKSPACE>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("Go to previous directory in stack"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<RIGHT/LEFT>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("change explorer tab"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<UP/DOWN>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("move up/down in list"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<PGUP/PGDOWN>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("scroll up/down in list quickly"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<ENTER>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("enter directory"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<SPACE>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("upload/download file"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<CANC>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("delete file"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<D>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("make directory"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<G>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("goto path"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<H>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("show help"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<I>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("show info about the selected file or directory"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<Q>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("Quit TermSCP"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<R>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("rename file"),
|
||||
])),
|
||||
ListItem::new(Spans::from(vec![
|
||||
Span::styled(
|
||||
"<U>",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::raw("go to parent directory"),
|
||||
])),
|
||||
];
|
||||
List::new(cmds)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default())
|
||||
.title("Help"),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
}
|
||||
|
||||
/// align_text_center
|
||||
///
|
||||
/// Align text to center for a given width
|
||||
fn align_text_center(text: &str, width: u16) -> String {
|
||||
let indent_size: usize = match (width as usize) >= text.len() {
|
||||
// NOTE: The check prevents underflow
|
||||
true => (width as usize - text.len()) / 2,
|
||||
false => 0,
|
||||
};
|
||||
textwrap::indent(
|
||||
text,
|
||||
(0..indent_size).map(|_| " ").collect::<String>().as_str(),
|
||||
)
|
||||
}
|
||||
|
||||
/// ### elide_wrkdir_path
|
||||
///
|
||||
/// Elide working directory path if longer than width + host.len
|
||||
/// In this case, the path is formatted to {ANCESTOR[0]}/.../{PARENT[0]}/{BASENAME}
|
||||
fn elide_wrkdir_path(wrkdir: &Path, host: &str, width: u16) -> PathBuf {
|
||||
let fmt_path: String = format!("{}", wrkdir.display());
|
||||
// NOTE: +5 is const
|
||||
match fmt_path.len() + host.len() + 5 > width as usize {
|
||||
false => PathBuf::from(wrkdir),
|
||||
true => {
|
||||
// Elide
|
||||
let ancestors_len: usize = wrkdir.ancestors().count();
|
||||
let mut ancestors = wrkdir.ancestors();
|
||||
let mut elided_path: PathBuf = PathBuf::new();
|
||||
// If ancestors_len's size is bigger than 2, push count - 2
|
||||
if ancestors_len > 2 {
|
||||
elided_path.push(ancestors.nth(ancestors_len - 2).unwrap());
|
||||
}
|
||||
// If ancestors_len is bigger than 3, push '...' and parent too
|
||||
if ancestors_len > 3 {
|
||||
elided_path.push("...");
|
||||
if let Some(parent) = wrkdir.ancestors().nth(1) {
|
||||
elided_path.push(parent.file_name().unwrap());
|
||||
}
|
||||
}
|
||||
// Push file_name
|
||||
if let Some(name) = wrkdir.file_name() {
|
||||
elided_path.push(name);
|
||||
}
|
||||
elided_path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,34 @@
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
use super::{FileTransferActivity, InputField, InputMode, LogLevel, LogRecord, PopupType};
|
||||
/**
|
||||
* 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::{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;
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### log
|
||||
@@ -34,53 +43,111 @@ impl FileTransferActivity {
|
||||
}
|
||||
// Eventually push front the new record
|
||||
self.log_records.push_front(record);
|
||||
// Set log index
|
||||
self.log_index = 0;
|
||||
// Update log
|
||||
let msg = self.update_logbox();
|
||||
self.update(msg);
|
||||
}
|
||||
|
||||
/// ### create_quit_popup
|
||||
/// ### log_and_alert
|
||||
///
|
||||
/// Create quit popup input mode (since must be shared between different input handlers)
|
||||
pub(super) fn create_disconnect_popup(&mut self) -> InputMode {
|
||||
InputMode::Popup(PopupType::YesNo(
|
||||
String::from("Are you sure you want to disconnect?"),
|
||||
FileTransferActivity::disconnect,
|
||||
FileTransferActivity::callback_nothing_to_do,
|
||||
))
|
||||
/// 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());
|
||||
// Update log
|
||||
let msg = self.update_logbox();
|
||||
self.update(msg);
|
||||
}
|
||||
|
||||
/// ### create_quit_popup
|
||||
/// ### init_config_client
|
||||
///
|
||||
/// Create quit popup input mode (since must be shared between different input handlers)
|
||||
pub(super) fn create_quit_popup(&mut self) -> InputMode {
|
||||
InputMode::Popup(PopupType::YesNo(
|
||||
String::from("Are you sure you want to quit?"),
|
||||
FileTransferActivity::disconnect_and_quit,
|
||||
FileTransferActivity::callback_nothing_to_do,
|
||||
))
|
||||
}
|
||||
|
||||
/// ### switch_input_field
|
||||
///
|
||||
/// Switch input field based on current input field
|
||||
pub(super) fn switch_input_field(&mut self) {
|
||||
self.input_field = match self.input_field {
|
||||
InputField::Explorer => InputField::Logs,
|
||||
InputField::Logs => InputField::Explorer,
|
||||
/// Initialize configuration client if possible.
|
||||
/// This function doesn't return errors.
|
||||
pub(super) fn init_config_client() -> Option<ConfigClient> {
|
||||
match environment::init_config_dir() {
|
||||
Ok(termscp_dir) => match termscp_dir {
|
||||
Some(termscp_dir) => {
|
||||
// Make configuration file path and ssh keys path
|
||||
let (config_path, ssh_keys_path): (PathBuf, PathBuf) =
|
||||
environment::get_config_paths(termscp_dir.as_path());
|
||||
match ConfigClient::new(config_path.as_path(), ssh_keys_path.as_path()) {
|
||||
Ok(config_client) => Some(config_client),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### set_progress
|
||||
/// ### make_ssh_storage
|
||||
///
|
||||
/// Calculate progress percentage based on current progress
|
||||
pub(super) fn set_progress(&mut self, it: usize, sz: usize) {
|
||||
let mut prog: f64 = ((it as f64) * 100.0) / (sz as f64);
|
||||
// Check value
|
||||
if prog > 100.0 {
|
||||
prog = 100.0;
|
||||
} else if prog < 0.0 {
|
||||
prog = 0.0;
|
||||
/// Make ssh storage from `ConfigClient` if possible, empty otherwise
|
||||
pub(super) fn make_ssh_storage(cli: Option<&ConfigClient>) -> SshKeyStorage {
|
||||
match cli {
|
||||
Some(cli) => SshKeyStorage::storage_from_config(cli),
|
||||
None => SshKeyStorage::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### 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
|
||||
pub(super) fn setup_text_editor(&self) {
|
||||
if let Some(config_cli) = self.context.as_ref().unwrap().config_client.as_ref() {
|
||||
// Set text editor
|
||||
env::set_var("EDITOR", config_cli.get_text_editor());
|
||||
}
|
||||
}
|
||||
|
||||
/// ### read_input_event
|
||||
///
|
||||
/// Read one event.
|
||||
/// Returns whether at least one event has been handled
|
||||
pub(super) fn read_input_event(&mut self) -> bool {
|
||||
if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() {
|
||||
// Handle event
|
||||
let msg = self.view.on(event);
|
||||
self.update(msg);
|
||||
// Return true
|
||||
true
|
||||
} else {
|
||||
// Error
|
||||
false
|
||||
}
|
||||
self.transfer_progress = prog;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,169 +2,89 @@
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// This module is split into files, cause it's just too big
|
||||
mod callbacks;
|
||||
mod input;
|
||||
mod layout;
|
||||
mod actions;
|
||||
mod misc;
|
||||
mod session;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
// Dependencies
|
||||
extern crate chrono;
|
||||
extern crate crossterm;
|
||||
extern crate textwrap;
|
||||
extern crate tui;
|
||||
extern crate unicode_width;
|
||||
|
||||
// locals
|
||||
use super::{Activity, Context};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
|
||||
// File transfer
|
||||
use super::{Activity, Context, ExitReason};
|
||||
use crate::filetransfer::ftp_transfer::FtpFileTransfer;
|
||||
use crate::filetransfer::scp_transfer::ScpFileTransfer;
|
||||
use crate::filetransfer::sftp_transfer::SftpFileTransfer;
|
||||
use crate::filetransfer::FileTransfer;
|
||||
use crate::filetransfer::{FileTransfer, FileTransferProtocol};
|
||||
use crate::fs::explorer::FileExplorer;
|
||||
use crate::fs::FsEntry;
|
||||
use crate::system::config_client::ConfigClient;
|
||||
use crate::ui::layout::view::View;
|
||||
|
||||
// Includes
|
||||
use chrono::{DateTime, Local};
|
||||
use crossterm::event::Event as InputEvent;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tui::style::Color;
|
||||
|
||||
// Types
|
||||
type DialogCallback = fn(&mut FileTransferActivity);
|
||||
type OnInputSubmitCallback = fn(&mut FileTransferActivity, String);
|
||||
// -- Storage keys
|
||||
|
||||
/// ### FileTransferParams
|
||||
///
|
||||
/// Holds connection parameters for file transfers
|
||||
pub struct FileTransferParams {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub protocol: FileTransferProtocol,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
const STORAGE_EXPLORER_WIDTH: &str = "FILETRANSFER_EXPLORER_WIDTH";
|
||||
const STORAGE_LOGBOX_WIDTH: &str = "LOGBOX_WIDTH";
|
||||
|
||||
/// ### InputField
|
||||
///
|
||||
/// Input field selected
|
||||
#[derive(std::cmp::PartialEq)]
|
||||
enum InputField {
|
||||
Explorer,
|
||||
Logs,
|
||||
}
|
||||
// -- components
|
||||
|
||||
/// ### DialogYesNoOption
|
||||
///
|
||||
/// Current yes/no dialog option
|
||||
#[derive(std::cmp::PartialEq, Clone)]
|
||||
enum DialogYesNoOption {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
/// ## PopupType
|
||||
///
|
||||
/// PopupType describes the type of popup
|
||||
#[derive(Clone)]
|
||||
enum PopupType {
|
||||
Alert(Color, String), // Block color; Block text
|
||||
Fatal(String), // Must quit after being hidden
|
||||
FileInfo, // Show info about current file
|
||||
Help, // Show Help
|
||||
Input(String, OnInputSubmitCallback), // Input description; Callback for submit
|
||||
Progress(String), // Progress block text
|
||||
Wait(String), // Wait block text
|
||||
YesNo(String, DialogCallback, DialogCallback), // Yes, no callback
|
||||
}
|
||||
|
||||
/// ## InputMode
|
||||
///
|
||||
/// InputMode describes the current input mode
|
||||
/// Each input mode handle the input events in a different way
|
||||
#[derive(Clone)]
|
||||
enum InputMode {
|
||||
Explorer,
|
||||
Popup(PopupType),
|
||||
}
|
||||
|
||||
/// ## FileExplorer
|
||||
///
|
||||
/// File explorer states
|
||||
struct FileExplorer {
|
||||
pub index: usize,
|
||||
pub files: Vec<FsEntry>,
|
||||
dirstack: VecDeque<PathBuf>,
|
||||
}
|
||||
|
||||
impl FileExplorer {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new FileExplorer
|
||||
pub fn new() -> FileExplorer {
|
||||
FileExplorer {
|
||||
index: 0,
|
||||
files: Vec::new(),
|
||||
dirstack: VecDeque::with_capacity(16),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### pushd
|
||||
///
|
||||
/// push directory to stack
|
||||
pub fn pushd(&mut self, dir: &Path) {
|
||||
// Check if stack overflows the size
|
||||
if self.dirstack.len() + 1 > 16 {
|
||||
self.dirstack.pop_back(); // Start cleaning events from back
|
||||
}
|
||||
// Eventually push front the new record
|
||||
self.dirstack.push_front(PathBuf::from(dir));
|
||||
}
|
||||
|
||||
/// ### popd
|
||||
///
|
||||
/// Pop directory from the stack and return the directory
|
||||
pub fn popd(&mut self) -> Option<PathBuf> {
|
||||
self.dirstack.pop_front()
|
||||
}
|
||||
|
||||
/// ### sort_files_by_name
|
||||
///
|
||||
/// Sort explorer files by their name
|
||||
pub fn sort_files_by_name(&mut self) {
|
||||
self.files.sort_by_key(|x: &FsEntry| match x {
|
||||
FsEntry::Directory(dir) => dir.name.as_str().to_lowercase(),
|
||||
FsEntry::File(file) => file.name.as_str().to_lowercase(),
|
||||
});
|
||||
}
|
||||
}
|
||||
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_TEXT_ERROR: &str = "TEXT_ERROR";
|
||||
const COMPONENT_TEXT_FATAL: &str = "TEXT_FATAL";
|
||||
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
|
||||
const COMPONENT_TEXT_WAIT: &str = "TEXT_WAIT";
|
||||
const COMPONENT_INPUT_COPY: &str = "INPUT_COPY";
|
||||
const COMPONENT_INPUT_EXEC: &str = "INPUT_EXEC";
|
||||
const COMPONENT_INPUT_FIND: &str = "INPUT_FIND";
|
||||
const COMPONENT_INPUT_GOTO: &str = "INPUT_GOTO";
|
||||
const COMPONENT_INPUT_MKDIR: &str = "INPUT_MKDIR";
|
||||
const COMPONENT_INPUT_NEWFILE: &str = "INPUT_NEWFILE";
|
||||
const COMPONENT_INPUT_RENAME: &str = "INPUT_RENAME";
|
||||
const COMPONENT_INPUT_SAVEAS: &str = "INPUT_SAVEAS";
|
||||
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_LIST_FILEINFO: &str = "LIST_FILEINFO";
|
||||
|
||||
/// ## FileExplorerTab
|
||||
///
|
||||
@@ -172,6 +92,8 @@ impl FileExplorer {
|
||||
enum FileExplorerTab {
|
||||
Local,
|
||||
Remote,
|
||||
FindLocal, // Find result tab
|
||||
FindRemote, // Find result tab
|
||||
}
|
||||
|
||||
/// ## LogLevel
|
||||
@@ -199,63 +121,131 @@ impl LogRecord {
|
||||
pub fn new(level: LogLevel, msg: &str) -> LogRecord {
|
||||
LogRecord {
|
||||
time: Local::now(),
|
||||
level: level,
|
||||
level,
|
||||
msg: String::from(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
|
||||
pub struct FileTransferActivity {
|
||||
pub disconnected: bool, // Has disconnected from remote?
|
||||
pub quit: bool, // Has quit term scp?
|
||||
exit_reason: Option<ExitReason>, // Exit reason
|
||||
context: Option<Context>, // Context holder
|
||||
params: FileTransferParams, // FT connection params
|
||||
view: View, // View
|
||||
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
|
||||
log_index: usize, // Current log index entry selected
|
||||
log_records: VecDeque<LogRecord>, // Log records
|
||||
log_size: usize, // Log records size (max)
|
||||
input_mode: InputMode, // Current input mode
|
||||
input_field: InputField, // Current selected input mode
|
||||
input_txt: String, // Input text
|
||||
choice_opt: DialogYesNoOption, // Dialog popup selected option
|
||||
transfer_progress: f64, // Current write/read progress (percentage)
|
||||
transfer_started: Instant, // Instant when progress has started
|
||||
transfer: TransferStates, // Transfer states
|
||||
}
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new FileTransferActivity
|
||||
pub fn new(params: FileTransferParams) -> FileTransferActivity {
|
||||
let protocol: FileTransferProtocol = params.protocol.clone();
|
||||
pub fn new(protocol: FileTransferProtocol) -> FileTransferActivity {
|
||||
// Get config client
|
||||
let config_client: Option<ConfigClient> = Self::init_config_client();
|
||||
FileTransferActivity {
|
||||
disconnected: false,
|
||||
quit: false,
|
||||
exit_reason: None,
|
||||
context: None,
|
||||
view: View::init(),
|
||||
client: match protocol {
|
||||
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new()),
|
||||
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new(
|
||||
Self::make_ssh_storage(config_client.as_ref()),
|
||||
)),
|
||||
FileTransferProtocol::Ftp(ftps) => Box::new(FtpFileTransfer::new(ftps)),
|
||||
FileTransferProtocol::Scp => Box::new(ScpFileTransfer::new()),
|
||||
FileTransferProtocol::Scp => Box::new(ScpFileTransfer::new(
|
||||
Self::make_ssh_storage(config_client.as_ref()),
|
||||
)),
|
||||
},
|
||||
params: params,
|
||||
local: FileExplorer::new(),
|
||||
remote: FileExplorer::new(),
|
||||
local: Self::build_explorer(config_client.as_ref()),
|
||||
remote: Self::build_explorer(config_client.as_ref()),
|
||||
found: None,
|
||||
tab: FileExplorerTab::Local,
|
||||
log_index: 0,
|
||||
log_records: VecDeque::with_capacity(256), // 256 events is enough I guess
|
||||
log_size: 256, // Must match with capacity
|
||||
input_mode: InputMode::Explorer,
|
||||
input_field: InputField::Explorer,
|
||||
input_txt: String::new(),
|
||||
choice_opt: DialogYesNoOption::Yes,
|
||||
transfer_progress: 0.0,
|
||||
transfer_started: Instant::now(),
|
||||
transfer: TransferStates::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,11 +265,22 @@ impl Activity for FileTransferActivity {
|
||||
// Set context
|
||||
self.context = Some(context);
|
||||
// Clear terminal
|
||||
let _ = self.context.as_mut().unwrap().terminal.clear();
|
||||
self.context.as_mut().unwrap().clear_screen();
|
||||
// Put raw mode on enabled
|
||||
let _ = enable_raw_mode();
|
||||
// Set working directory
|
||||
let pwd: PathBuf = self.context.as_ref().unwrap().local.pwd();
|
||||
// Get files at current wd
|
||||
self.local_scan(self.context.as_ref().unwrap().local.pwd().as_path());
|
||||
self.local_scan(pwd.as_path());
|
||||
self.local.wrkdir = pwd;
|
||||
// Configure text editor
|
||||
self.setup_text_editor();
|
||||
// init view
|
||||
self.init();
|
||||
// Verify error state from context
|
||||
if let Some(err) = self.context.as_mut().unwrap().get_error() {
|
||||
self.mount_fatal(&err);
|
||||
}
|
||||
}
|
||||
|
||||
/// ### on_draw
|
||||
@@ -287,45 +288,42 @@ impl Activity for FileTransferActivity {
|
||||
/// `on_draw` is the function which draws the graphical interface.
|
||||
/// This function must be called at each tick to refresh the interface
|
||||
fn on_draw(&mut self) {
|
||||
let mut redraw: bool = false; // Should ui actually be redrawned?
|
||||
// Context must be something
|
||||
// Should ui actually be redrawned?
|
||||
let mut redraw: bool = false;
|
||||
// Context must be something
|
||||
if self.context.is_none() {
|
||||
return;
|
||||
}
|
||||
let is_explorer_mode: bool = match self.input_mode {
|
||||
InputMode::Explorer => true,
|
||||
_ => false,
|
||||
};
|
||||
// Check if connected
|
||||
if !self.client.is_connected() && is_explorer_mode {
|
||||
// Check if connected (popup must be None, otherwise would try reconnecting in loop in case of error)
|
||||
if !self.client.is_connected() && self.view.get_props(COMPONENT_TEXT_FATAL).is_none() {
|
||||
let params = self.context.as_ref().unwrap().ft_params.as_ref().unwrap();
|
||||
let msg: String = format!("Connecting to {}:{}...", params.address, params.port);
|
||||
// Set init state to connecting popup
|
||||
self.input_mode = InputMode::Popup(PopupType::Wait(format!(
|
||||
"Connecting to {}:{}...",
|
||||
self.params.address, self.params.port
|
||||
)));
|
||||
self.mount_wait(msg.as_str());
|
||||
// Force ui draw
|
||||
self.draw();
|
||||
self.view();
|
||||
// Connect to remote
|
||||
self.connect();
|
||||
// Redraw
|
||||
redraw = true;
|
||||
}
|
||||
// Handle input events
|
||||
if let Ok(event) = self.context.as_ref().unwrap().input_hnd.read_event() {
|
||||
// Iterate over input events
|
||||
if let Some(event) = event {
|
||||
// Handle event
|
||||
self.handle_input_event(&event);
|
||||
// Set redraw to true
|
||||
redraw = true;
|
||||
}
|
||||
}
|
||||
// Handle input events (if false, becomes true; otherwise remains true)
|
||||
redraw |= self.read_input_event();
|
||||
// @! draw interface
|
||||
if redraw {
|
||||
self.draw();
|
||||
self.view();
|
||||
}
|
||||
}
|
||||
|
||||
/// ### will_umount
|
||||
///
|
||||
/// `will_umount` is the method which must be able to report to the activity manager, whether
|
||||
/// the activity should be terminated or not.
|
||||
/// If not, the call will return `None`, otherwise return`Some(ExitReason)`
|
||||
fn will_umount(&self) -> Option<&ExitReason> {
|
||||
self.exit_reason.as_ref()
|
||||
}
|
||||
|
||||
/// ### on_destroy
|
||||
///
|
||||
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
|
||||
@@ -340,7 +338,7 @@ impl Activity for FileTransferActivity {
|
||||
// Clear terminal and return
|
||||
match self.context.take() {
|
||||
Some(mut ctx) => {
|
||||
let _ = ctx.terminal.clear();
|
||||
ctx.clear_screen();
|
||||
Some(ctx)
|
||||
}
|
||||
None => None,
|
||||
|
||||
991
src/ui/activities/filetransfer_activity/update.rs
Normal file
@@ -0,0 +1,991 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// deps
|
||||
extern crate 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,
|
||||
};
|
||||
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};
|
||||
// externals
|
||||
use bytesize::ByteSize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tui::style::Color;
|
||||
|
||||
impl FileTransferActivity {
|
||||
// -- update
|
||||
|
||||
/// ### update
|
||||
///
|
||||
/// Update auth activity model based on msg
|
||||
/// The function exits when returns None
|
||||
pub(super) fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> {
|
||||
let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg));
|
||||
// Match msg
|
||||
match ref_msg {
|
||||
None => None, // Exit after None
|
||||
Some(msg) => match msg {
|
||||
// -- local tab
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_RIGHT) => {
|
||||
// Change tab
|
||||
self.view.active(COMPONENT_EXPLORER_REMOTE);
|
||||
self.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);
|
||||
}
|
||||
// Reload file list component
|
||||
self.update_local_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
// Match selected file
|
||||
let mut entry: Option<FsEntry> = None;
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_A) => {
|
||||
// Toggle hidden files
|
||||
self.local.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 {
|
||||
self.mount_file_info(&file);
|
||||
}
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_L) => {
|
||||
// Reload directory
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.update_local_filelist()
|
||||
}
|
||||
// -- remote tab
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_LEFT) => {
|
||||
// Change tab
|
||||
self.view.active(COMPONENT_EXPLORER_LOCAL);
|
||||
self.tab = FileExplorerTab::Local;
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
// Match selected file
|
||||
let mut entry: Option<FsEntry> = None;
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_BACKSPACE) => {
|
||||
// Go to previous directory
|
||||
if let Some(d) = self.remote.popd() {
|
||||
self.remote_changedir(d.as_path(), false);
|
||||
}
|
||||
// Reload file list component
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_A) => {
|
||||
// Toggle hidden files
|
||||
self.remote.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 {
|
||||
self.mount_file_info(&file);
|
||||
}
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_L) => {
|
||||
// Reload directory
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// Reload file list component
|
||||
self.update_remote_filelist()
|
||||
}
|
||||
// -- common explorer keys
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_B)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_B) => {
|
||||
// Show sorting file
|
||||
self.mount_file_sorting();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_C)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_C) => {
|
||||
self.mount_copy();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_D)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_D) => {
|
||||
self.mount_mkdir();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_F)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_F) => {
|
||||
self.mount_find_input();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_G)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_G) => {
|
||||
self.mount_goto();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_H)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_H) => {
|
||||
self.mount_help();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_N)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_N) => {
|
||||
self.mount_newfile();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_Q)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_Q)
|
||||
| (COMPONENT_LOG_BOX, &MSG_KEY_CHAR_Q) => {
|
||||
self.mount_quit();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_R)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_R) => {
|
||||
// Mount rename
|
||||
self.mount_rename();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_S)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_S)
|
||||
| (COMPONENT_EXPLORER_FIND, &MSG_KEY_CHAR_S) => {
|
||||
// Mount save as
|
||||
self.mount_saveas();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_X)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_X) => {
|
||||
// Mount exec
|
||||
self.mount_exec();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_ESC)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_ESC)
|
||||
| (COMPONENT_LOG_BOX, &MSG_KEY_ESC) => {
|
||||
self.mount_disconnect();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_DEL)
|
||||
| (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_E)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_DEL)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_E)
|
||||
| (COMPONENT_EXPLORER_FIND, &MSG_KEY_DEL)
|
||||
| (COMPONENT_EXPLORER_FIND, &MSG_KEY_CHAR_E) => {
|
||||
self.mount_radio_delete();
|
||||
None
|
||||
}
|
||||
// -- find result explorer
|
||||
(COMPONENT_EXPLORER_FIND, &MSG_KEY_ESC) => {
|
||||
// Umount find
|
||||
self.umount_find();
|
||||
// Finalize find
|
||||
self.finalize_find();
|
||||
None
|
||||
}
|
||||
(COMPONENT_EXPLORER_FIND, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
// Find changedir
|
||||
self.action_find_changedir(*idx);
|
||||
// Umount find
|
||||
self.umount_find();
|
||||
// Finalize find
|
||||
self.finalize_find();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
(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,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- switch to log
|
||||
(COMPONENT_EXPLORER_LOCAL, &MSG_KEY_TAB)
|
||||
| (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_TAB) => {
|
||||
self.view.active(COMPONENT_LOG_BOX); // Active log box
|
||||
None
|
||||
}
|
||||
// -- Log box
|
||||
(COMPONENT_LOG_BOX, &MSG_KEY_TAB) => {
|
||||
self.view.blur(); // Blur log box
|
||||
None
|
||||
}
|
||||
// -- copy popup
|
||||
(COMPONENT_INPUT_COPY, &MSG_KEY_ESC) => {
|
||||
self.umount_copy();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_COPY, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
// Copy file
|
||||
match self.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 {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- exec popup
|
||||
(COMPONENT_INPUT_EXEC, &MSG_KEY_ESC) => {
|
||||
self.umount_exec();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_EXEC, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
// Exex command
|
||||
match self.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 {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- find popup
|
||||
(COMPONENT_INPUT_FIND, &MSG_KEY_ESC) => {
|
||||
self.umount_find_input();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_FIND, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
self.umount_find_input();
|
||||
// Find
|
||||
let res: Result<Vec<FsEntry>, String> = match self.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"),
|
||||
};
|
||||
// Match result
|
||||
match res {
|
||||
Err(err) => {
|
||||
// Mount error
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
Ok(files) => {
|
||||
// Create explorer and load files
|
||||
let mut explorer = Self::build_found_explorer();
|
||||
explorer.set_files(files);
|
||||
self.found = Some(explorer);
|
||||
// Mount result widget
|
||||
self.mount_find(input);
|
||||
self.update_find_list();
|
||||
// Initialize tab
|
||||
self.tab = match self.tab {
|
||||
FileExplorerTab::Local => FileExplorerTab::FindLocal,
|
||||
FileExplorerTab::Remote => FileExplorerTab::FindRemote,
|
||||
_ => FileExplorerTab::FindLocal,
|
||||
};
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
// -- goto popup
|
||||
(COMPONENT_INPUT_GOTO, &MSG_KEY_ESC) => {
|
||||
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()),
|
||||
_ => panic!("Found tab doesn't support GOTO"),
|
||||
}
|
||||
// Umount
|
||||
self.umount_goto();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- make directory
|
||||
(COMPONENT_INPUT_MKDIR, &MSG_KEY_ESC) => {
|
||||
self.umount_mkdir();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_MKDIR, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.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 {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- new file
|
||||
(COMPONENT_INPUT_NEWFILE, &MSG_KEY_ESC) => {
|
||||
self.umount_newfile();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_NEWFILE, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.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 {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- rename
|
||||
(COMPONENT_INPUT_RENAME, &MSG_KEY_ESC) => {
|
||||
self.umount_rename();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_RENAME, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.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 {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- save as
|
||||
(COMPONENT_INPUT_SAVEAS, &MSG_KEY_ESC) => {
|
||||
self.umount_saveas();
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_SAVEAS, Msg::OnSubmit(Payload::Text(input))) => {
|
||||
match self.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.umount_saveas();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
// NOTE: Swapped is intentional
|
||||
FileExplorerTab::Local => self.update_remote_filelist(),
|
||||
FileExplorerTab::Remote => self.update_local_filelist(),
|
||||
FileExplorerTab::FindLocal => self.update_remote_filelist(),
|
||||
FileExplorerTab::FindRemote => self.update_local_filelist(),
|
||||
}
|
||||
}
|
||||
// -- fileinfo
|
||||
(COMPONENT_LIST_FILEINFO, &MSG_KEY_ENTER)
|
||||
| (COMPONENT_LIST_FILEINFO, &MSG_KEY_ESC) => {
|
||||
self.umount_file_info();
|
||||
None
|
||||
}
|
||||
// -- delete
|
||||
(COMPONENT_RADIO_DELETE, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
self.umount_radio_delete();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_DELETE, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
// Choice is 'YES'
|
||||
match self.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.umount_radio_delete();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
FileExplorerTab::FindLocal => self.update_local_filelist(),
|
||||
FileExplorerTab::FindRemote => self.update_remote_filelist(),
|
||||
}
|
||||
}
|
||||
// -- disconnect
|
||||
(COMPONENT_RADIO_DISCONNECT, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
self.umount_disconnect();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_DISCONNECT, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
self.disconnect();
|
||||
self.umount_disconnect();
|
||||
None
|
||||
}
|
||||
// -- quit
|
||||
(COMPONENT_RADIO_QUIT, &MSG_KEY_ESC)
|
||||
| (COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
self.disconnect_and_quit();
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
// -- sorting
|
||||
(COMPONENT_RADIO_SORTING, &MSG_KEY_ESC) => {
|
||||
self.umount_file_sorting();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_SORTING, Msg::OnSubmit(Payload::Unsigned(mode))) => {
|
||||
// Get sorting mode
|
||||
let sorting: FileSorting = match mode {
|
||||
1 => FileSorting::ByModifyTime,
|
||||
2 => FileSorting::ByCreationTime,
|
||||
3 => FileSorting::BySize,
|
||||
_ => FileSorting::ByName,
|
||||
};
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => self.local.sort_by(sorting),
|
||||
FileExplorerTab::Remote => self.remote.sort_by(sorting),
|
||||
_ => panic!("Found result doesn't support SORTING"),
|
||||
}
|
||||
self.umount_file_sorting();
|
||||
// Reload files
|
||||
match self.tab {
|
||||
FileExplorerTab::Local => self.update_local_filelist(),
|
||||
FileExplorerTab::Remote => self.update_remote_filelist(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// -- error
|
||||
(COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) => {
|
||||
self.umount_error();
|
||||
None
|
||||
}
|
||||
// -- fatal
|
||||
(COMPONENT_TEXT_FATAL, &MSG_KEY_ESC) | (COMPONENT_TEXT_FATAL, &MSG_KEY_ENTER) => {
|
||||
self.exit_reason = Some(super::ExitReason::Disconnect);
|
||||
None
|
||||
}
|
||||
// -- help
|
||||
(COMPONENT_TEXT_HELP, &MSG_KEY_ESC) | (COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) => {
|
||||
self.umount_help();
|
||||
None
|
||||
}
|
||||
// -- progress bar
|
||||
(COMPONENT_PROGRESS_BAR, &MSG_KEY_CTRL_C) => {
|
||||
// Set transfer aborted to True
|
||||
self.transfer.aborted = true;
|
||||
None
|
||||
}
|
||||
// -- fallback
|
||||
(_, _) => None, // Nothing to do
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update_local_filelist
|
||||
///
|
||||
/// 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()
|
||||
{
|
||||
Some(props) => {
|
||||
// Get width
|
||||
let width: usize = self
|
||||
.context
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store
|
||||
.get_unsigned(super::STORAGE_EXPLORER_WIDTH)
|
||||
.unwrap_or(256);
|
||||
let hostname: String = match hostname::get() {
|
||||
Ok(h) => {
|
||||
let hostname: String = h.as_os_str().to_string_lossy().to_string();
|
||||
let tokens: Vec<&str> = hostname.split('.').collect();
|
||||
String::from(*tokens.get(0).unwrap_or(&"localhost"))
|
||||
}
|
||||
Err(_) => String::from("localhost"),
|
||||
};
|
||||
let hostname: String = format!(
|
||||
"{}:{} ",
|
||||
hostname,
|
||||
FileTransferActivity::elide_wrkdir_path(
|
||||
self.local.wrkdir.as_path(),
|
||||
hostname.as_str(),
|
||||
width
|
||||
)
|
||||
.display()
|
||||
);
|
||||
let files: Vec<TextSpan> = self
|
||||
.local
|
||||
.iter_files()
|
||||
.map(|x: &FsEntry| TextSpan::from(self.local.fmt_file(x)))
|
||||
.collect();
|
||||
// Update
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(hostname), Some(files)))
|
||||
.build();
|
||||
// Update
|
||||
self.view.update(super::COMPONENT_EXPLORER_LOCAL, props)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update_remote_filelist
|
||||
///
|
||||
/// 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()
|
||||
{
|
||||
Some(props) => {
|
||||
// Get width
|
||||
let width: usize = self
|
||||
.context
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.store
|
||||
.get_unsigned(super::STORAGE_EXPLORER_WIDTH)
|
||||
.unwrap_or(256);
|
||||
let params = self.context.as_ref().unwrap().ft_params.as_ref().unwrap();
|
||||
let hostname: String = format!(
|
||||
"{}:{} ",
|
||||
params.address,
|
||||
FileTransferActivity::elide_wrkdir_path(
|
||||
self.remote.wrkdir.as_path(),
|
||||
params.address.as_str(),
|
||||
width
|
||||
)
|
||||
.display()
|
||||
);
|
||||
let files: Vec<TextSpan> = self
|
||||
.remote
|
||||
.iter_files()
|
||||
.map(|x: &FsEntry| TextSpan::from(self.remote.fmt_file(x)))
|
||||
.collect();
|
||||
// Update
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(hostname), Some(files)))
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_EXPLORER_REMOTE, props)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### update_logbox
|
||||
///
|
||||
/// Update log box
|
||||
pub(super) fn update_logbox(&mut self) -> Option<(String, Msg)> {
|
||||
match self.view.get_props(super::COMPONENT_LOG_BOX).as_mut() {
|
||||
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();
|
||||
}
|
||||
let fg = match record.level {
|
||||
LogLevel::Error => Color::Red,
|
||||
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(),
|
||||
" ",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let table = table.build();
|
||||
let props = props
|
||||
.with_texts(TextParts::table(Some(String::from("Log")), table))
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_LOG_BOX, props)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_progress_bar(&mut self, text: String) -> Option<(String, Msg)> {
|
||||
match self.view.get_props(COMPONENT_PROGRESS_BAR).as_mut() {
|
||||
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))
|
||||
.build();
|
||||
self.view.update(COMPONENT_PROGRESS_BAR, props)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### finalize_find
|
||||
///
|
||||
/// Finalize find process
|
||||
fn finalize_find(&mut self) {
|
||||
// Set found to none
|
||||
self.found = None;
|
||||
// Restore tab
|
||||
self.tab = match self.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() {
|
||||
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()
|
||||
.unwrap()
|
||||
.iter_files()
|
||||
.map(|x: &FsEntry| TextSpan::from(self.found.as_ref().unwrap().fmt_file(x)))
|
||||
.collect();
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(title), Some(file_texts)))
|
||||
.build();
|
||||
self.view.update(COMPONENT_EXPLORER_FIND, props)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### elide_wrkdir_path
|
||||
///
|
||||
/// Elide working directory path if longer than width + host.len
|
||||
/// In this case, the path is formatted to {ANCESTOR[0]}/.../{PARENT[0]}/{BASENAME}
|
||||
fn elide_wrkdir_path(wrkdir: &Path, host: &str, width: usize) -> PathBuf {
|
||||
let fmt_path: String = format!("{}", wrkdir.display());
|
||||
// NOTE: +5 is const
|
||||
match fmt_path.len() + host.len() + 5 > width {
|
||||
false => PathBuf::from(wrkdir),
|
||||
true => {
|
||||
// Elide
|
||||
let ancestors_len: usize = wrkdir.ancestors().count();
|
||||
let mut ancestors = wrkdir.ancestors();
|
||||
let mut elided_path: PathBuf = PathBuf::new();
|
||||
// If ancestors_len's size is bigger than 2, push count - 2
|
||||
if ancestors_len > 2 {
|
||||
elided_path.push(ancestors.nth(ancestors_len - 2).unwrap());
|
||||
}
|
||||
// If ancestors_len is bigger than 3, push '...' and parent too
|
||||
if ancestors_len > 3 {
|
||||
elided_path.push("...");
|
||||
if let Some(parent) = wrkdir.ancestors().nth(1) {
|
||||
elided_path.push(parent.file_name().unwrap());
|
||||
}
|
||||
}
|
||||
// Push file_name
|
||||
if let Some(name) = wrkdir.file_name() {
|
||||
elided_path.push(name);
|
||||
}
|
||||
elided_path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
998
src/ui/activities/filetransfer_activity/view.rs
Normal file
@@ -0,0 +1,998 @@
|
||||
//! ## FileTransferActivity
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Deps
|
||||
extern crate bytesize;
|
||||
extern crate hostname;
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
extern crate users;
|
||||
// locals
|
||||
use super::{Context, FileExplorerTab, 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::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;
|
||||
// Ext
|
||||
use bytesize::ByteSize;
|
||||
use std::path::PathBuf;
|
||||
use tui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
widgets::Clear,
|
||||
};
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
use users::{get_group_by_gid, get_user_by_uid};
|
||||
|
||||
impl FileTransferActivity {
|
||||
// -- init
|
||||
|
||||
/// ### init
|
||||
///
|
||||
/// Initialize file transfer activity's view
|
||||
pub(super) fn init(&mut self) {
|
||||
// Mount local file explorer
|
||||
self.view.mount(
|
||||
super::COMPONENT_EXPLORER_LOCAL,
|
||||
Box::new(FileList::new(
|
||||
PropsBuilder::default()
|
||||
.with_background(Color::Yellow)
|
||||
.with_foreground(Color::Yellow)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Mount remote file explorer
|
||||
self.view.mount(
|
||||
super::COMPONENT_EXPLORER_REMOTE,
|
||||
Box::new(FileList::new(
|
||||
PropsBuilder::default()
|
||||
.with_background(Color::LightBlue)
|
||||
.with_foreground(Color::LightBlue)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Mount log box
|
||||
self.view.mount(
|
||||
super::COMPONENT_LOG_BOX,
|
||||
Box::new(LogBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
.bold()
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Update components
|
||||
let _ = self.update_local_filelist();
|
||||
let _ = self.update_remote_filelist();
|
||||
// Give focus to local explorer
|
||||
self.view.active(super::COMPONENT_EXPLORER_LOCAL);
|
||||
}
|
||||
|
||||
// -- view
|
||||
|
||||
/// ### view
|
||||
///
|
||||
/// View gui
|
||||
pub(super) fn view(&mut self) {
|
||||
let mut context: Context = self.context.take().unwrap();
|
||||
let store: &mut Store = &mut context.store;
|
||||
let _ = context.terminal.draw(|f| {
|
||||
// Prepare chunks
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage(70), // Explorer
|
||||
Constraint::Percentage(30), // Log
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(f.size());
|
||||
// Create explorer chunks
|
||||
let tabs_chunks = Layout::default()
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||
.direction(Direction::Horizontal)
|
||||
.split(chunks[0]);
|
||||
// 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 {
|
||||
FileExplorerTab::FindLocal => {
|
||||
self.view
|
||||
.render(super::COMPONENT_EXPLORER_FIND, f, tabs_chunks[0])
|
||||
}
|
||||
_ => self
|
||||
.view
|
||||
.render(super::COMPONENT_EXPLORER_LOCAL, f, tabs_chunks[0]),
|
||||
}
|
||||
// @! Remote explorer (Find or default)
|
||||
match self.tab {
|
||||
FileExplorerTab::FindRemote => {
|
||||
self.view
|
||||
.render(super::COMPONENT_EXPLORER_FIND, f, tabs_chunks[1])
|
||||
}
|
||||
_ => self
|
||||
.view
|
||||
.render(super::COMPONENT_EXPLORER_REMOTE, f, tabs_chunks[1]),
|
||||
}
|
||||
// Draw log box
|
||||
self.view.render(super::COMPONENT_LOG_BOX, f, chunks[1]);
|
||||
// @! Draw popups
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_COPY) {
|
||||
if props.build().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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_PROGRESS_BAR, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_DELETE) {
|
||||
if props.build().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 {
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_DISCONNECT, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_RADIO_QUIT) {
|
||||
if props.build().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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
let popup = draw_area_in(f.size(), 50, 80);
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.view.render(super::COMPONENT_TEXT_HELP, f, popup);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Re-give context
|
||||
self.context = Some(context);
|
||||
}
|
||||
|
||||
// -- partials
|
||||
|
||||
/// ### mount_error
|
||||
///
|
||||
/// Mount error box
|
||||
pub(super) fn mount_error(&mut self, text: &str) {
|
||||
// Mount
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_ERROR,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to error
|
||||
self.view.active(super::COMPONENT_TEXT_ERROR);
|
||||
}
|
||||
|
||||
/// ### umount_error
|
||||
///
|
||||
/// Umount error message
|
||||
pub(super) fn umount_error(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_ERROR);
|
||||
}
|
||||
|
||||
pub(super) fn mount_fatal(&mut self, text: &str) {
|
||||
// Mount
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_FATAL,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to error
|
||||
self.view.active(super::COMPONENT_TEXT_FATAL);
|
||||
}
|
||||
|
||||
pub(super) fn mount_wait(&mut self, text: &str) {
|
||||
// Mount
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_WAIT,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::White)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to info
|
||||
self.view.active(super::COMPONENT_TEXT_WAIT);
|
||||
}
|
||||
|
||||
pub(super) fn umount_wait(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_WAIT);
|
||||
}
|
||||
|
||||
/// ### mount_quit
|
||||
///
|
||||
/// Mount quit popup
|
||||
pub(super) fn mount_quit(&mut self) {
|
||||
// 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("Are you sure you want to quit?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_RADIO_QUIT);
|
||||
}
|
||||
|
||||
/// ### umount_quit
|
||||
///
|
||||
/// Umount quit popup
|
||||
pub(super) fn umount_quit(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_QUIT);
|
||||
}
|
||||
|
||||
/// ### mount_disconnect
|
||||
///
|
||||
/// Mount disconnect popup
|
||||
pub(super) fn mount_disconnect(&mut self) {
|
||||
// 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(
|
||||
Some(String::from("Are you sure you want to disconnect?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_RADIO_DISCONNECT);
|
||||
}
|
||||
|
||||
/// ### umount_disconnect
|
||||
///
|
||||
/// Umount disconnect popup
|
||||
pub(super) fn umount_disconnect(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_DISCONNECT);
|
||||
}
|
||||
|
||||
pub(super) fn mount_copy(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_COPY,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Insert destination name")),
|
||||
None,
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_COPY);
|
||||
}
|
||||
|
||||
pub(super) fn umount_copy(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_COPY);
|
||||
}
|
||||
|
||||
pub(super) fn mount_exec(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_EXEC,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("Execute command")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_EXEC);
|
||||
}
|
||||
|
||||
pub(super) fn umount_exec(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_EXEC);
|
||||
}
|
||||
|
||||
pub(super) fn mount_find(&mut self, search: &str) {
|
||||
// Get color
|
||||
let color: Color = match self.tab {
|
||||
FileExplorerTab::Local | FileExplorerTab::FindLocal => Color::Yellow,
|
||||
FileExplorerTab::Remote | FileExplorerTab::FindRemote => Color::LightBlue,
|
||||
};
|
||||
// Mount component
|
||||
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![]),
|
||||
))
|
||||
.with_background(color)
|
||||
.with_foreground(color)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to explorer findd
|
||||
self.view.active(super::COMPONENT_EXPLORER_FIND);
|
||||
}
|
||||
|
||||
pub(super) fn umount_find(&mut self) {
|
||||
self.view.umount(super::COMPONENT_EXPLORER_FIND);
|
||||
}
|
||||
|
||||
pub(super) fn mount_find_input(&mut self) {
|
||||
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,
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to input find
|
||||
self.view.active(super::COMPONENT_INPUT_FIND);
|
||||
}
|
||||
|
||||
pub(super) fn umount_find_input(&mut self) {
|
||||
// Umount input find
|
||||
self.view.umount(super::COMPONENT_INPUT_FIND);
|
||||
}
|
||||
|
||||
pub(super) fn mount_goto(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_GOTO,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Change working directory")),
|
||||
None,
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_GOTO);
|
||||
}
|
||||
|
||||
pub(super) fn umount_goto(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_GOTO);
|
||||
}
|
||||
|
||||
pub(super) fn mount_mkdir(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_MKDIR,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Insert directory name")),
|
||||
None,
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_MKDIR);
|
||||
}
|
||||
|
||||
pub(super) fn umount_mkdir(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_MKDIR);
|
||||
}
|
||||
|
||||
pub(super) fn mount_newfile(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_NEWFILE,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("New file name")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_NEWFILE);
|
||||
}
|
||||
|
||||
pub(super) fn umount_newfile(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_NEWFILE);
|
||||
}
|
||||
|
||||
pub(super) fn mount_rename(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_RENAME,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("Insert new name")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_RENAME);
|
||||
}
|
||||
|
||||
pub(super) fn umount_rename(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_RENAME);
|
||||
}
|
||||
|
||||
pub(super) fn mount_saveas(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_SAVEAS,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(Some(String::from("Save as...")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_SAVEAS);
|
||||
}
|
||||
|
||||
pub(super) fn umount_saveas(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_SAVEAS);
|
||||
}
|
||||
|
||||
pub(super) fn mount_progress_bar(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_PROGRESS_BAR,
|
||||
Box::new(ProgressBar::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
.with_background(Color::Black)
|
||||
.with_texts(TextParts::new(Some(String::from("Please wait")), None))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_PROGRESS_BAR);
|
||||
}
|
||||
|
||||
pub(super) fn umount_progress_bar(&mut self) {
|
||||
self.view.umount(super::COMPONENT_PROGRESS_BAR);
|
||||
}
|
||||
|
||||
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(),
|
||||
_ => panic!("You can't mount file sorting when in found result"),
|
||||
};
|
||||
let index: usize = match sorting {
|
||||
FileSorting::ByCreationTime => 2,
|
||||
FileSorting::ByModifyTime => 1,
|
||||
FileSorting::ByName => 0,
|
||||
FileSorting::BySize => 3,
|
||||
};
|
||||
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(
|
||||
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))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_RADIO_SORTING);
|
||||
}
|
||||
|
||||
pub(super) fn umount_file_sorting(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_SORTING);
|
||||
}
|
||||
|
||||
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(
|
||||
Some(String::from("Delete file")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_RADIO_DELETE);
|
||||
}
|
||||
|
||||
pub(super) fn umount_radio_delete(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_DELETE);
|
||||
}
|
||||
|
||||
pub(super) fn mount_file_info(&mut self, file: &FsEntry) {
|
||||
let mut texts: TableBuilder = TableBuilder::default();
|
||||
// Abs path
|
||||
let real_path: Option<PathBuf> = {
|
||||
let real_file: FsEntry = file.get_realfile();
|
||||
match real_file.get_abs_path() != file.get_abs_path() {
|
||||
true => Some(real_file.get_abs_path()),
|
||||
false => None,
|
||||
}
|
||||
};
|
||||
let path: String = match real_path {
|
||||
Some(symlink) => format!("{} -> {}", file.get_abs_path().display(), symlink.display()),
|
||||
None => format!("{}", file.get_abs_path().display()),
|
||||
};
|
||||
// Make texts
|
||||
texts.add_col(TextSpan::from("Path: ")).add_col(
|
||||
TextSpanBuilder::new(path.as_str())
|
||||
.with_foreground(Color::Yellow)
|
||||
.build(),
|
||||
);
|
||||
if let Some(filetype) = file.get_ftype() {
|
||||
texts
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("File type: "))
|
||||
.add_col(
|
||||
TextSpanBuilder::new(filetype.as_str())
|
||||
.with_foreground(Color::LightGreen)
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
let (bsize, size): (ByteSize, usize) = (ByteSize(file.get_size() as u64), file.get_size());
|
||||
texts.add_row().add_col(TextSpan::from("Size: ")).add_col(
|
||||
TextSpanBuilder::new(format!("{} ({})", bsize, size).as_str())
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
);
|
||||
let ctime: String = fmt_time(file.get_creation_time(), "%b %d %Y %H:%M:%S");
|
||||
let atime: String = fmt_time(file.get_last_access_time(), "%b %d %Y %H:%M:%S");
|
||||
let mtime: String = fmt_time(file.get_creation_time(), "%b %d %Y %H:%M:%S");
|
||||
texts
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("Creation time: "))
|
||||
.add_col(
|
||||
TextSpanBuilder::new(ctime.as_str())
|
||||
.with_foreground(Color::LightGreen)
|
||||
.build(),
|
||||
);
|
||||
texts
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("Last modified time: "))
|
||||
.add_col(
|
||||
TextSpanBuilder::new(mtime.as_str())
|
||||
.with_foreground(Color::LightBlue)
|
||||
.build(),
|
||||
);
|
||||
texts
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("Last access time: "))
|
||||
.add_col(
|
||||
TextSpanBuilder::new(atime.as_str())
|
||||
.with_foreground(Color::LightRed)
|
||||
.build(),
|
||||
);
|
||||
// User
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
let username: String = match file.get_user() {
|
||||
Some(uid) => match get_user_by_uid(uid) {
|
||||
Some(user) => user.name().to_string_lossy().to_string(),
|
||||
None => uid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let username: String = format!("{}", file.get_user().unwrap_or(0));
|
||||
// Group
|
||||
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
|
||||
let group: String = match file.get_group() {
|
||||
Some(gid) => match get_group_by_gid(gid) {
|
||||
Some(group) => group.name().to_string_lossy().to_string(),
|
||||
None => gid.to_string(),
|
||||
},
|
||||
None => String::from("0"),
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
let group: String = format!("{}", file.get_group().unwrap_or(0));
|
||||
texts.add_row().add_col(TextSpan::from("User: ")).add_col(
|
||||
TextSpanBuilder::new(username.as_str())
|
||||
.with_foreground(Color::LightYellow)
|
||||
.build(),
|
||||
);
|
||||
texts.add_row().add_col(TextSpan::from("Group: ")).add_col(
|
||||
TextSpanBuilder::new(group.as_str())
|
||||
.with_foreground(Color::Blue)
|
||||
.build(),
|
||||
);
|
||||
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(),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_LIST_FILEINFO);
|
||||
}
|
||||
|
||||
pub(super) fn umount_file_info(&mut self) {
|
||||
self.view.umount(super::COMPONENT_LIST_FILEINFO);
|
||||
}
|
||||
|
||||
/// ### mount_help
|
||||
///
|
||||
/// Mount help
|
||||
pub(super) fn mount_help(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_HELP,
|
||||
Box::new(Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
Some(String::from("Help")),
|
||||
TableBuilder::default()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<ESC>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Disconnect"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<TAB>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(
|
||||
" Switch between explorer and logs",
|
||||
))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<BACKSPACE>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Go to previous directory"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<RIGHT/LEFT>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Change explorer tab"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<UP/DOWN>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Move up/down in list"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<ENTER>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Enter directory"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<SPACE>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Upload/Download file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<A>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Toggle hidden files"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<B>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Change file sorting mode"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<C>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Copy"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<D>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Make directory"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<G>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Go to path"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<H>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Show help"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<I>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Show info about selected file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<L>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Reload directory content"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<N>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Create new file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<O>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Open text file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<Q>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Quit termscp"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<R>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Rename file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<S>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Save file as"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<U>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Go to parent directory"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<DEL|E>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Delete selected file"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+C>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Interrupt file transfer"))
|
||||
.build(),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active help
|
||||
self.view.active(super::COMPONENT_TEXT_HELP);
|
||||
}
|
||||
|
||||
pub(super) fn umount_help(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_HELP);
|
||||
}
|
||||
}
|
||||
217
src/ui/activities/keymap.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
//! ## Keymap
|
||||
//!
|
||||
//! Keymap contains pub constants which can be used in the `update` function to match messages
|
||||
|
||||
/**
|
||||
* 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::ui::layout::Msg;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
// -- Special keys
|
||||
|
||||
pub const MSG_KEY_ENTER: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_ESC: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Esc,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_TAB: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Tab,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_DEL: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Delete,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_BACKSPACE: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_DOWN: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_LEFT: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_RIGHT: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_UP: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Up,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_SPACE: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char(' '),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
|
||||
// -- char keys
|
||||
|
||||
pub const MSG_KEY_CHAR_A: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_B: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_C: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_D: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_E: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_F: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_G: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('g'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_H: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_I: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('i'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
/*
|
||||
pub const MSG_KEY_CHAR_J: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_K: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
*/
|
||||
pub const MSG_KEY_CHAR_L: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
/*
|
||||
pub const MSG_KEY_CHAR_M: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('m'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
*/
|
||||
pub const MSG_KEY_CHAR_N: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_O: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('o'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
/*
|
||||
pub const MSG_KEY_CHAR_P: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
*/
|
||||
pub const MSG_KEY_CHAR_Q: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_R: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('r'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_S: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('s'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
/*
|
||||
pub const MSG_KEY_CHAR_T: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
*/
|
||||
pub const MSG_KEY_CHAR_U: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('u'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
/*
|
||||
pub const MSG_KEY_CHAR_V: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('v'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
pub const MSG_KEY_CHAR_W: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('w'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
*/
|
||||
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,
|
||||
});
|
||||
*/
|
||||
|
||||
// -- control
|
||||
pub const MSG_KEY_CTRL_C: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
});
|
||||
pub const MSG_KEY_CTRL_E: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('e'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
});
|
||||
pub const MSG_KEY_CTRL_H: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('h'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
});
|
||||
pub const MSG_KEY_CTRL_N: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
});
|
||||
pub const MSG_KEY_CTRL_R: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('r'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
});
|
||||
pub const MSG_KEY_CTRL_S: Msg = Msg::OnKey(KeyEvent {
|
||||
code: KeyCode::Char('s'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
});
|
||||
@@ -3,35 +3,48 @@
|
||||
//! `activities` is the module which provides all the different activities
|
||||
//! each activity identifies a layout with its own logic in the UI
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Locals
|
||||
use super::context::Context;
|
||||
|
||||
// keymap
|
||||
pub(crate) mod keymap;
|
||||
// Activities
|
||||
pub mod auth_activity;
|
||||
pub mod filetransfer_activity;
|
||||
pub mod setup_activity;
|
||||
|
||||
// Activity trait
|
||||
// -- Exit reason
|
||||
|
||||
pub enum ExitReason {
|
||||
Quit,
|
||||
Connect,
|
||||
Disconnect,
|
||||
EnterSetup,
|
||||
}
|
||||
|
||||
// -- Activity trait
|
||||
|
||||
pub trait Activity {
|
||||
/// ### on_create
|
||||
@@ -47,6 +60,13 @@ pub trait Activity {
|
||||
/// This function must be called at each tick to refresh the interface
|
||||
fn on_draw(&mut self);
|
||||
|
||||
/// ### will_umount
|
||||
///
|
||||
/// `will_umount` is the method which must be able to report to the activity manager, whether
|
||||
/// the activity should be terminated or not.
|
||||
/// If not, the call will return `None`, otherwise return`Some(ExitReason)`
|
||||
fn will_umount(&self) -> Option<&ExitReason>;
|
||||
|
||||
/// ### on_destroy
|
||||
///
|
||||
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
|
||||
|
||||
156
src/ui/activities/setup_activity/actions.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! ## SetupActivity
|
||||
//!
|
||||
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
|
||||
//! work on termscp configuration
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Locals
|
||||
use super::SetupActivity;
|
||||
use crate::ui::layout::Payload;
|
||||
// Ext
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use std::env;
|
||||
|
||||
impl SetupActivity {
|
||||
/// ### action_save_config
|
||||
///
|
||||
/// Save configuration
|
||||
pub(super) fn action_save_config(&mut self) -> Result<(), String> {
|
||||
// Collect input values
|
||||
self.collect_input_values();
|
||||
self.save_config()
|
||||
}
|
||||
|
||||
/// ### action_reset_config
|
||||
///
|
||||
/// Reset configuration input fields
|
||||
pub(super) fn action_reset_config(&mut self) -> Result<(), String> {
|
||||
match self.reset_config_changes() {
|
||||
Err(err) => Err(err),
|
||||
Ok(_) => {
|
||||
self.load_input_values();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_delete_ssh_key
|
||||
///
|
||||
/// delete of a ssh key
|
||||
pub(super) fn action_delete_ssh_key(&mut self) {
|
||||
// Get key
|
||||
if let Some(config_cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
// get index
|
||||
let idx: Option<usize> = match self.view.get_value(super::COMPONENT_LIST_SSH_KEYS) {
|
||||
Some(Payload::Unsigned(idx)) => Some(idx),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(idx) = idx {
|
||||
let key: Option<String> = config_cli.iter_ssh_keys().nth(idx).cloned();
|
||||
if let Some(key) = key {
|
||||
match config_cli.get_ssh_key(&key) {
|
||||
Ok(opt) => {
|
||||
if let Some((host, username, _)) = opt {
|
||||
if let Err(err) =
|
||||
self.delete_ssh_key(host.as_str(), username.as_str())
|
||||
{
|
||||
// Report error
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Report error
|
||||
self.mount_error(
|
||||
format!("Could not get ssh key \"{}\": {}", key, err).as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### action_new_ssh_key
|
||||
///
|
||||
/// Create a new ssh key
|
||||
pub(super) fn action_new_ssh_key(&mut self) {
|
||||
if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
// get parameters
|
||||
let host: String = match self.view.get_value(super::COMPONENT_INPUT_SSH_HOST) {
|
||||
Some(Payload::Text(host)) => host,
|
||||
_ => String::new(),
|
||||
};
|
||||
let username: String = match self.view.get_value(super::COMPONENT_INPUT_SSH_USERNAME) {
|
||||
Some(Payload::Text(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();
|
||||
// Leave alternate mode
|
||||
if let Some(ctx) = self.context.as_mut() {
|
||||
ctx.leave_alternate_screen();
|
||||
}
|
||||
// Re-enable raw mode
|
||||
let _ = enable_raw_mode();
|
||||
// Write key to file
|
||||
match edit::edit(placeholder.as_bytes()) {
|
||||
Ok(rsa_key) => {
|
||||
// Remove placeholder from `rsa_key`
|
||||
let rsa_key: String = rsa_key.as_str().replace(placeholder.as_str(), "");
|
||||
if rsa_key.is_empty() {
|
||||
// Report error: empty key
|
||||
self.mount_error("SSH key is empty!");
|
||||
} else {
|
||||
// Add key
|
||||
if let Err(err) =
|
||||
self.add_ssh_key(host.as_str(), username.as_str(), rsa_key.as_str())
|
||||
{
|
||||
self.mount_error(
|
||||
format!("Could not create new private key: {}", err).as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Report error
|
||||
self.mount_error(
|
||||
format!("Could not write private key to file: {}", err).as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Restore terminal
|
||||
if let Some(ctx) = self.context.as_mut() {
|
||||
// Clear screen
|
||||
ctx.clear_screen();
|
||||
// Enter alternate mode
|
||||
ctx.enter_alternate_screen();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/ui/activities/setup_activity/config.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! ## SetupActivity
|
||||
//!
|
||||
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
|
||||
//! work on termscp configuration
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Locals
|
||||
use super::SetupActivity;
|
||||
// Ext
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use std::env;
|
||||
|
||||
impl SetupActivity {
|
||||
/// ### save_config
|
||||
///
|
||||
/// Save configuration
|
||||
pub(super) fn save_config(&mut self) -> Result<(), String> {
|
||||
match self.context.as_ref().unwrap().config_client.as_ref() {
|
||||
Some(cli) => match cli.write_config() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(format!("Could not save configuration: {}", err)),
|
||||
},
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### reset_config_changes
|
||||
///
|
||||
/// Reset configuration changes; pratically read config from file, overwriting any change made
|
||||
/// since last write action
|
||||
pub(super) fn reset_config_changes(&mut self) -> Result<(), String> {
|
||||
match self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
Some(cli) => match cli.read_config() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(format!("Could not restore configuration: {}", err)),
|
||||
},
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### delete_ssh_key
|
||||
///
|
||||
/// Delete ssh key from config cli
|
||||
pub(super) fn delete_ssh_key(&mut self, host: &str, username: &str) -> Result<(), String> {
|
||||
match self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
Some(cli) => match cli.del_ssh_key(host, username) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(format!(
|
||||
"Could not delete ssh key \"{}@{}\": {}",
|
||||
host, username, err
|
||||
)),
|
||||
},
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// ### edit_ssh_key
|
||||
///
|
||||
/// Edit selected ssh key
|
||||
pub(super) fn edit_ssh_key(&mut self, idx: usize) -> Result<(), String> {
|
||||
match self.context.as_mut() {
|
||||
None => Ok(()),
|
||||
Some(ctx) => {
|
||||
// Set editor if config client exists
|
||||
if let Some(config_cli) = ctx.config_client.as_ref() {
|
||||
env::set_var("EDITOR", config_cli.get_text_editor());
|
||||
}
|
||||
// Prepare terminal
|
||||
let _ = disable_raw_mode();
|
||||
// Leave alternate mode
|
||||
ctx.leave_alternate_screen();
|
||||
// Get result
|
||||
let result: Result<(), String> = match ctx.config_client.as_ref() {
|
||||
Some(config_cli) => match config_cli.iter_ssh_keys().nth(idx) {
|
||||
Some(key) => {
|
||||
// Get key path
|
||||
match config_cli.get_ssh_key(key) {
|
||||
Ok(ssh_key) => match ssh_key {
|
||||
None => Ok(()),
|
||||
Some((_, _, key_path)) => {
|
||||
match edit::edit_file(key_path.as_path()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
Err(format!("Could not edit ssh key: {}", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(err) => Err(format!("Could not read ssh key: {}", err)),
|
||||
}
|
||||
}
|
||||
None => Ok(()),
|
||||
},
|
||||
None => Ok(()),
|
||||
};
|
||||
// Restore terminal
|
||||
// Clear screen
|
||||
ctx.clear_screen();
|
||||
// Enter alternate mode
|
||||
ctx.enter_alternate_screen();
|
||||
// Re-enable raw mode
|
||||
let _ = enable_raw_mode();
|
||||
// Return result
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### add_ssh_key
|
||||
///
|
||||
/// Add provided ssh key to config client
|
||||
pub(super) fn add_ssh_key(
|
||||
&mut self,
|
||||
host: &str,
|
||||
username: &str,
|
||||
rsa_key: &str,
|
||||
) -> Result<(), String> {
|
||||
match self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
Some(cli) => {
|
||||
// Add key to client
|
||||
match cli.add_ssh_key(host, username, rsa_key) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => Err(format!("Could not add SSH key: {}", err)),
|
||||
}
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
174
src/ui/activities/setup_activity/mod.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
//! ## SetupActivity
|
||||
//!
|
||||
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
|
||||
//! work on termscp configuration
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Submodules
|
||||
mod actions;
|
||||
mod config;
|
||||
mod update;
|
||||
mod view;
|
||||
|
||||
// Deps
|
||||
extern crate crossterm;
|
||||
extern crate tui;
|
||||
|
||||
// Locals
|
||||
use super::{Activity, Context, ExitReason};
|
||||
use crate::ui::layout::view::View;
|
||||
// Ext
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
|
||||
// -- components
|
||||
const COMPONENT_TEXT_HELP: &str = "TEXT_HELP";
|
||||
const COMPONENT_TEXT_FOOTER: &str = "TEXT_FOOTER";
|
||||
const COMPONENT_TEXT_ERROR: &str = "TEXT_ERROR";
|
||||
const COMPONENT_RADIO_QUIT: &str = "RADIO_QUIT";
|
||||
const COMPONENT_RADIO_SAVE: &str = "RADIO_SAVE";
|
||||
const COMPONENT_INPUT_TEXT_EDITOR: &str = "INPUT_TEXT_EDITOR";
|
||||
const COMPONENT_RADIO_DEFAULT_PROTOCOL: &str = "RADIO_DEFAULT_PROTOCOL";
|
||||
const COMPONENT_RADIO_HIDDEN_FILES: &str = "RADIO_HIDDEN_FILES";
|
||||
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_RADIO_TAB: &str = "RADIO_TAB";
|
||||
const COMPONENT_LIST_SSH_KEYS: &str = "LIST_SSH_KEYS";
|
||||
const COMPONENT_INPUT_SSH_HOST: &str = "INPUT_SSH_HOST";
|
||||
const COMPONENT_INPUT_SSH_USERNAME: &str = "INPUT_SSH_USERNAME";
|
||||
const COMPONENT_RADIO_DEL_SSH_KEY: &str = "RADIO_DEL_SSH_KEY";
|
||||
|
||||
/// ### ViewLayout
|
||||
///
|
||||
/// Current view layout
|
||||
#[derive(std::cmp::PartialEq)]
|
||||
enum ViewLayout {
|
||||
SetupForm,
|
||||
SshKeys,
|
||||
}
|
||||
|
||||
/// ## SetupActivity
|
||||
///
|
||||
/// Setup activity states holder
|
||||
pub struct SetupActivity {
|
||||
exit_reason: Option<ExitReason>,
|
||||
context: Option<Context>, // Context holder
|
||||
view: View, // View
|
||||
layout: ViewLayout, // View layout
|
||||
redraw: bool,
|
||||
}
|
||||
|
||||
impl Default for SetupActivity {
|
||||
fn default() -> Self {
|
||||
// Initialize user input
|
||||
let mut user_input_buffer: Vec<String> = Vec::with_capacity(16);
|
||||
for _ in 0..16 {
|
||||
user_input_buffer.push(String::new());
|
||||
}
|
||||
SetupActivity {
|
||||
exit_reason: None,
|
||||
context: None,
|
||||
view: View::init(),
|
||||
layout: ViewLayout::SetupForm,
|
||||
redraw: true, // Draw at first `on_draw`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Activity for SetupActivity {
|
||||
/// ### on_create
|
||||
///
|
||||
/// `on_create` is the function which must be called to initialize the activity.
|
||||
/// `on_create` must initialize all the data structures used by the activity
|
||||
/// Context is taken from activity manager and will be released only when activity is destroyed
|
||||
fn on_create(&mut self, context: Context) {
|
||||
// Set context
|
||||
self.context = Some(context);
|
||||
// Clear terminal
|
||||
self.context.as_mut().unwrap().clear_screen();
|
||||
// Put raw mode on enabled
|
||||
let _ = enable_raw_mode();
|
||||
// Init view
|
||||
self.init_setup();
|
||||
// Verify error state from context
|
||||
if let Some(err) = self.context.as_mut().unwrap().get_error() {
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// ### on_draw
|
||||
///
|
||||
/// `on_draw` is the function which draws the graphical interface.
|
||||
/// This function must be called at each tick to refresh the interface
|
||||
fn on_draw(&mut self) {
|
||||
// Context must be something
|
||||
if self.context.is_none() {
|
||||
return;
|
||||
}
|
||||
// Read one event
|
||||
if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() {
|
||||
// Set redraw to true
|
||||
self.redraw = true;
|
||||
// Handle event
|
||||
let msg = self.view.on(event);
|
||||
self.update(msg);
|
||||
}
|
||||
// Redraw if necessary
|
||||
if self.redraw {
|
||||
// View
|
||||
self.view();
|
||||
// Redraw back to false
|
||||
self.redraw = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### will_umount
|
||||
///
|
||||
/// `will_umount` is the method which must be able to report to the activity manager, whether
|
||||
/// the activity should be terminated or not.
|
||||
/// If not, the call will return `None`, otherwise return`Some(ExitReason)`
|
||||
fn will_umount(&self) -> Option<&ExitReason> {
|
||||
self.exit_reason.as_ref()
|
||||
}
|
||||
|
||||
/// ### on_destroy
|
||||
///
|
||||
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
|
||||
/// This function must be called once before terminating the activity.
|
||||
/// This function finally releases the context
|
||||
fn on_destroy(&mut self) -> Option<Context> {
|
||||
// Disable raw mode
|
||||
let _ = disable_raw_mode();
|
||||
self.context.as_ref()?;
|
||||
// Clear terminal and return
|
||||
match self.context.take() {
|
||||
Some(mut ctx) => {
|
||||
ctx.clear_screen();
|
||||
Some(ctx)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
262
src/ui/activities/setup_activity/update.rs
Normal file
@@ -0,0 +1,262 @@
|
||||
//! ## SetupActivity
|
||||
//!
|
||||
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
|
||||
//! work on termscp configuration
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// locals
|
||||
use super::{
|
||||
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,
|
||||
};
|
||||
use crate::ui::activities::keymap::*;
|
||||
use crate::ui::layout::{Msg, Payload};
|
||||
|
||||
impl SetupActivity {
|
||||
/// ### update
|
||||
///
|
||||
/// Update auth activity model based on msg
|
||||
/// The function exits when returns None
|
||||
pub(super) fn update(&mut self, msg: Option<(String, Msg)>) -> Option<(String, Msg)> {
|
||||
let ref_msg: Option<(&str, &Msg)> = msg.as_ref().map(|(s, msg)| (s.as_str(), msg));
|
||||
// Match msg
|
||||
match ref_msg {
|
||||
None => None,
|
||||
Some(msg) => match msg {
|
||||
// Input field <DOWN>
|
||||
(COMPONENT_INPUT_TEXT_EDITOR, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_RADIO_DEFAULT_PROTOCOL);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_DEFAULT_PROTOCOL, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_RADIO_HIDDEN_FILES);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_HIDDEN_FILES, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_RADIO_UPDATES);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_UPDATES, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_RADIO_GROUP_DIRS);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_GROUP_DIRS, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_INPUT_FILE_FMT);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_FILE_FMT, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_INPUT_TEXT_EDITOR);
|
||||
None
|
||||
}
|
||||
// Input field <UP>
|
||||
(COMPONENT_INPUT_FILE_FMT, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_RADIO_GROUP_DIRS);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_GROUP_DIRS, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_RADIO_UPDATES);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_UPDATES, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_RADIO_HIDDEN_FILES);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_HIDDEN_FILES, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_RADIO_DEFAULT_PROTOCOL);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_DEFAULT_PROTOCOL, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_INPUT_TEXT_EDITOR);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_TEXT_EDITOR, &MSG_KEY_UP) => {
|
||||
self.view.active(COMPONENT_INPUT_FILE_FMT);
|
||||
None
|
||||
}
|
||||
// Error <ENTER> or <ESC>
|
||||
(COMPONENT_TEXT_ERROR, &MSG_KEY_ENTER) | (COMPONENT_TEXT_ERROR, &MSG_KEY_ESC) => {
|
||||
// Umount text error
|
||||
self.umount_error();
|
||||
None
|
||||
}
|
||||
// Exit
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
// Save changes
|
||||
if let Err(err) = self.action_save_config() {
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
// Exit
|
||||
self.exit_reason = Some(super::ExitReason::Quit);
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(Payload::Unsigned(1))) => {
|
||||
// Quit
|
||||
self.exit_reason = Some(super::ExitReason::Quit);
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_QUIT, Msg::OnSubmit(_)) => {
|
||||
// Umount popup
|
||||
self.umount_quit();
|
||||
None
|
||||
}
|
||||
// Close help
|
||||
(COMPONENT_TEXT_HELP, &MSG_KEY_ENTER) | (COMPONENT_TEXT_HELP, &MSG_KEY_ESC) => {
|
||||
// Umount help
|
||||
self.umount_help();
|
||||
None
|
||||
}
|
||||
// Delete key
|
||||
(COMPONENT_RADIO_DEL_SSH_KEY, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
// Delete key
|
||||
self.action_delete_ssh_key();
|
||||
// Reload ssh keys
|
||||
self.reload_ssh_keys();
|
||||
// Delete popup
|
||||
self.umount_del_ssh_key();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_DEL_SSH_KEY, Msg::OnSubmit(_)) => {
|
||||
// Umount
|
||||
self.umount_del_ssh_key();
|
||||
None
|
||||
}
|
||||
// Save popup
|
||||
(COMPONENT_RADIO_SAVE, Msg::OnSubmit(Payload::Unsigned(0))) => {
|
||||
// Save config
|
||||
if let Err(err) = self.action_save_config() {
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
self.umount_save_popup();
|
||||
None
|
||||
}
|
||||
(COMPONENT_RADIO_SAVE, Msg::OnSubmit(_)) => {
|
||||
// Umount radio save
|
||||
self.umount_save_popup();
|
||||
None
|
||||
}
|
||||
// Edit SSH Key
|
||||
// <TAB> Change view
|
||||
(COMPONENT_LIST_SSH_KEYS, &MSG_KEY_TAB) => {
|
||||
// Change view
|
||||
self.init_setup();
|
||||
None
|
||||
}
|
||||
// <CTRL+H> Show help
|
||||
(_, &MSG_KEY_CTRL_H) => {
|
||||
// Show help
|
||||
self.mount_help();
|
||||
None
|
||||
}
|
||||
// New key <DOWN>
|
||||
(COMPONENT_INPUT_SSH_HOST, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_INPUT_SSH_USERNAME);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_SSH_USERNAME, &MSG_KEY_DOWN) => {
|
||||
self.view.active(COMPONENT_INPUT_SSH_HOST);
|
||||
None
|
||||
}
|
||||
// New key <UP>
|
||||
(COMPONENT_INPUT_SSH_USERNAME, &MSG_KEY_UP)
|
||||
| (COMPONENT_INPUT_SSH_USERNAME, &MSG_KEY_TAB) => {
|
||||
self.view.active(COMPONENT_INPUT_SSH_HOST);
|
||||
None
|
||||
}
|
||||
(COMPONENT_INPUT_SSH_HOST, &MSG_KEY_UP)
|
||||
| (COMPONENT_INPUT_SSH_HOST, &MSG_KEY_TAB) => {
|
||||
self.view.active(COMPONENT_INPUT_SSH_USERNAME);
|
||||
None
|
||||
}
|
||||
// New key <ENTER>
|
||||
(COMPONENT_INPUT_SSH_HOST, Msg::OnSubmit(_))
|
||||
| (COMPONENT_INPUT_SSH_USERNAME, Msg::OnSubmit(_)) => {
|
||||
// Save ssh key
|
||||
self.action_new_ssh_key();
|
||||
self.umount_new_ssh_key();
|
||||
self.reload_ssh_keys();
|
||||
None
|
||||
}
|
||||
// New key <ESC>
|
||||
(COMPONENT_INPUT_SSH_HOST, &MSG_KEY_ESC)
|
||||
| (COMPONENT_INPUT_SSH_USERNAME, &MSG_KEY_ESC) => {
|
||||
// Umount new ssh key
|
||||
self.umount_new_ssh_key();
|
||||
None
|
||||
}
|
||||
// <CTRL+N> New key
|
||||
(COMPONENT_LIST_SSH_KEYS, &MSG_KEY_CTRL_N) => {
|
||||
// Show new key popup
|
||||
self.mount_new_ssh_key();
|
||||
None
|
||||
}
|
||||
// <ENTER> Edit key
|
||||
(COMPONENT_LIST_SSH_KEYS, Msg::OnSubmit(Payload::Unsigned(idx))) => {
|
||||
// Edit ssh key
|
||||
if let Err(err) = self.edit_ssh_key(*idx) {
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
None
|
||||
}
|
||||
// <DEL | CTRL+E> Show delete
|
||||
(COMPONENT_LIST_SSH_KEYS, &MSG_KEY_CTRL_E)
|
||||
| (COMPONENT_LIST_SSH_KEYS, &MSG_KEY_DEL) => {
|
||||
// Show delete key
|
||||
self.mount_del_ssh_key();
|
||||
None
|
||||
}
|
||||
(_, &MSG_KEY_TAB) => {
|
||||
// Change view
|
||||
self.init_ssh_keys();
|
||||
None
|
||||
}
|
||||
// <CTRL+R> Revert changes
|
||||
(_, &MSG_KEY_CTRL_R) => {
|
||||
// Revert changes
|
||||
if let Err(err) = self.action_reset_config() {
|
||||
self.mount_error(err.as_str());
|
||||
}
|
||||
None
|
||||
}
|
||||
// <CTRL+S> Save
|
||||
(_, &MSG_KEY_CTRL_S) => {
|
||||
// Show save
|
||||
self.mount_save_popup();
|
||||
None
|
||||
}
|
||||
// <ESC>
|
||||
(_, &MSG_KEY_ESC) => {
|
||||
// Mount quit prompt
|
||||
self.mount_quit();
|
||||
None
|
||||
}
|
||||
(_, _) => None, // Nothing to do
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
786
src/ui/activities/setup_activity/view.rs
Normal file
@@ -0,0 +1,786 @@
|
||||
//! ## SetupActivity
|
||||
//!
|
||||
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
|
||||
//! work on termscp configuration
|
||||
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* termscp - Copyright (c) 2021 Christian Visintin
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
// Locals
|
||||
use super::{Context, SetupActivity, ViewLayout};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::fs::explorer::GroupDirs;
|
||||
use crate::ui::layout::components::{
|
||||
bookmark_list::BookmarkList, input::Input, msgbox::MsgBox, radio_group::RadioGroup,
|
||||
table::Table, text::Text,
|
||||
};
|
||||
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;
|
||||
// Ext
|
||||
use std::path::PathBuf;
|
||||
use tui::{
|
||||
layout::{Constraint, Direction, Layout},
|
||||
style::Color,
|
||||
widgets::{Borders, Clear},
|
||||
};
|
||||
|
||||
impl SetupActivity {
|
||||
// -- view
|
||||
|
||||
/// ### init_setup
|
||||
///
|
||||
/// Initialize setup view
|
||||
pub(super) fn init_setup(&mut self) {
|
||||
// Init view
|
||||
self.view = View::init();
|
||||
// Common stuff
|
||||
// Radio tab
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_TAB,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightYellow)
|
||||
.with_background(Color::Black)
|
||||
.with_borders(Borders::BOTTOM)
|
||||
.with_texts(TextParts::new(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpan::from("User Interface"),
|
||||
TextSpan::from("SSH Keys"),
|
||||
]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(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(),
|
||||
]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Input fields
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_TEXT_EDITOR,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightGreen)
|
||||
.with_texts(TextParts::new(Some(String::from("Text editor")), None))
|
||||
.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(
|
||||
Some(String::from("Default file transfer protocol")),
|
||||
Some(vec![
|
||||
TextSpan::from("SFTP"),
|
||||
TextSpan::from("SCP"),
|
||||
TextSpan::from("FTP"),
|
||||
TextSpan::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(
|
||||
Some(String::from("Show hidden files (by default)")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::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(
|
||||
Some(String::from("Check for updates?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::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(
|
||||
Some(String::from("Group directories")),
|
||||
Some(vec![
|
||||
TextSpan::from("Display first"),
|
||||
TextSpan::from("Display Last"),
|
||||
TextSpan::from("No"),
|
||||
]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_FILE_FMT,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightBlue)
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("File formatter syntax")),
|
||||
None,
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Load values
|
||||
self.load_input_values();
|
||||
// Set view
|
||||
self.layout = ViewLayout::SetupForm;
|
||||
}
|
||||
|
||||
/// ### init_ssh_keys
|
||||
///
|
||||
/// Initialize ssh keys view
|
||||
pub(super) fn init_ssh_keys(&mut self) {
|
||||
// Init view
|
||||
self.view = View::init();
|
||||
// Common stuff
|
||||
// Radio tab
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_TAB,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightYellow)
|
||||
.with_background(Color::Black)
|
||||
.with_borders(Borders::BOTTOM)
|
||||
.with_texts(TextParts::new(
|
||||
None,
|
||||
Some(vec![
|
||||
TextSpan::from("User Interface"),
|
||||
TextSpan::from("SSH Keys"),
|
||||
]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(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(),
|
||||
]),
|
||||
))
|
||||
.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![])))
|
||||
.with_background(Color::LightGreen)
|
||||
.with_foreground(Color::Black)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus
|
||||
self.view.active(super::COMPONENT_LIST_SSH_KEYS);
|
||||
// Load keys
|
||||
self.reload_ssh_keys();
|
||||
// Set view
|
||||
self.layout = ViewLayout::SshKeys;
|
||||
}
|
||||
|
||||
/// ### view
|
||||
///
|
||||
/// View gui
|
||||
pub(super) fn view(&mut self) {
|
||||
let mut ctx: Context = self.context.take().unwrap();
|
||||
let _ = ctx.terminal.draw(|f| {
|
||||
// Prepare main chunks
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.margin(1)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(3), // Current tab
|
||||
Constraint::Percentage(90), // Main body
|
||||
Constraint::Length(3), // Help footer
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(f.size());
|
||||
// Render common widget
|
||||
self.view.render(super::COMPONENT_RADIO_TAB, f, chunks[0]);
|
||||
self.view.render(super::COMPONENT_TEXT_FOOTER, f, chunks[2]);
|
||||
match self.layout {
|
||||
ViewLayout::SetupForm => {
|
||||
// Make chunks
|
||||
let ui_cfg_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(3), // Text editor
|
||||
Constraint::Length(3), // Protocol tab
|
||||
Constraint::Length(3), // Hidden files
|
||||
Constraint::Length(3), // Updates tab
|
||||
Constraint::Length(3), // Group dirs
|
||||
Constraint::Length(3), // Format input
|
||||
Constraint::Length(1), // Empty ?
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(chunks[1]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_TEXT_EDITOR, f, ui_cfg_chunks[0]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, f, ui_cfg_chunks[1]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_HIDDEN_FILES, f, ui_cfg_chunks[2]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_UPDATES, f, ui_cfg_chunks[3]);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks[4]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_FILE_FMT, f, ui_cfg_chunks[5]);
|
||||
}
|
||||
ViewLayout::SshKeys => {
|
||||
let sshcfg_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Percentage(100)].as_ref())
|
||||
.split(chunks[1]);
|
||||
self.view
|
||||
.render(super::COMPONENT_LIST_SSH_KEYS, f, sshcfg_chunks[0]);
|
||||
}
|
||||
}
|
||||
// Popups
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
|
||||
if props.build().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 {
|
||||
// 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 {
|
||||
// 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 {
|
||||
// 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 {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 30, 10);
|
||||
f.render_widget(Clear, popup);
|
||||
self.view
|
||||
.render(super::COMPONENT_RADIO_DEL_SSH_KEY, f, popup);
|
||||
}
|
||||
}
|
||||
if let Some(mut props) = self.view.get_props(super::COMPONENT_INPUT_SSH_HOST) {
|
||||
if props.build().visible {
|
||||
// make popup
|
||||
let popup = draw_area_in(f.size(), 50, 20);
|
||||
f.render_widget(Clear, popup);
|
||||
let popup_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(3), // Host
|
||||
Constraint::Length(3), // Username
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(popup);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_SSH_HOST, f, popup_chunks[0]);
|
||||
self.view
|
||||
.render(super::COMPONENT_INPUT_SSH_USERNAME, f, popup_chunks[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Put context back to context
|
||||
self.context = Some(ctx);
|
||||
}
|
||||
|
||||
// -- mount
|
||||
|
||||
/// ### mount_error
|
||||
///
|
||||
/// Mount error box
|
||||
pub(super) fn mount_error(&mut self, text: &str) {
|
||||
// Mount
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_ERROR,
|
||||
Box::new(MsgBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::Red)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(None, Some(vec![TextSpan::from(text)])))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Give focus to error
|
||||
self.view.active(super::COMPONENT_TEXT_ERROR);
|
||||
}
|
||||
|
||||
/// ### umount_error
|
||||
///
|
||||
/// Umount error message
|
||||
pub(super) fn umount_error(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_ERROR);
|
||||
}
|
||||
|
||||
/// ### mount_del_ssh_key
|
||||
///
|
||||
/// Mount delete ssh key component
|
||||
pub(super) fn mount_del_ssh_key(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_DEL_SSH_KEY,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightRed)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Delete key?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.with_value(PropValue::Unsigned(1)) // Default: No
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active
|
||||
self.view.active(super::COMPONENT_RADIO_DEL_SSH_KEY);
|
||||
}
|
||||
|
||||
/// ### umount_del_ssh_key
|
||||
///
|
||||
/// Umount delete ssh key
|
||||
pub(super) fn umount_del_ssh_key(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_DEL_SSH_KEY);
|
||||
}
|
||||
|
||||
/// ### mount_new_ssh_key
|
||||
///
|
||||
/// Mount new ssh key prompt
|
||||
pub(super) fn mount_new_ssh_key(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_INPUT_SSH_HOST,
|
||||
Box::new(Input::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Hostname or address")),
|
||||
None,
|
||||
))
|
||||
.with_borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
|
||||
.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)
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
self.view.active(super::COMPONENT_INPUT_SSH_HOST);
|
||||
}
|
||||
|
||||
/// ### umount_new_ssh_key
|
||||
///
|
||||
/// Umount new ssh key prompt
|
||||
pub(super) fn umount_new_ssh_key(&mut self) {
|
||||
self.view.umount(super::COMPONENT_INPUT_SSH_HOST);
|
||||
self.view.umount(super::COMPONENT_INPUT_SSH_USERNAME);
|
||||
}
|
||||
|
||||
/// ### mount_quit
|
||||
///
|
||||
/// Mount quit popup
|
||||
pub(super) fn mount_quit(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_QUIT,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightRed)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Exit setup?")),
|
||||
Some(vec![
|
||||
TextSpan::from("Save"),
|
||||
TextSpan::from("Don't save"),
|
||||
TextSpan::from("Cancel"),
|
||||
]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active
|
||||
self.view.active(super::COMPONENT_RADIO_QUIT);
|
||||
}
|
||||
|
||||
/// ### umount_quit
|
||||
///
|
||||
/// Umount quit
|
||||
pub(super) fn umount_quit(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_QUIT);
|
||||
}
|
||||
|
||||
/// ### mount_save_popup
|
||||
///
|
||||
/// Mount save popup
|
||||
pub(super) fn mount_save_popup(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_RADIO_SAVE,
|
||||
Box::new(RadioGroup::new(
|
||||
PropsBuilder::default()
|
||||
.with_foreground(Color::LightYellow)
|
||||
.bold()
|
||||
.with_texts(TextParts::new(
|
||||
Some(String::from("Save changes?")),
|
||||
Some(vec![TextSpan::from("Yes"), TextSpan::from("No")]),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active
|
||||
self.view.active(super::COMPONENT_RADIO_SAVE);
|
||||
}
|
||||
|
||||
/// ### umount_quit
|
||||
///
|
||||
/// Umount quit
|
||||
pub(super) fn umount_save_popup(&mut self) {
|
||||
self.view.umount(super::COMPONENT_RADIO_SAVE);
|
||||
}
|
||||
|
||||
/// ### mount_help
|
||||
///
|
||||
/// Mount help
|
||||
pub(super) fn mount_help(&mut self) {
|
||||
self.view.mount(
|
||||
super::COMPONENT_TEXT_HELP,
|
||||
Box::new(Table::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
Some(String::from("Help")),
|
||||
TableBuilder::default()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<ESC>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Exit setup"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<TAB>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Change setup page"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<RIGHT/LEFT>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Change cursor"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<UP/DOWN>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Change input field"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<ENTER>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Select / Dismiss popup"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<DEL|E>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Delete SSH key"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+N>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" New SSH key"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+R>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Revert changes"))
|
||||
.add_row()
|
||||
.add_col(
|
||||
TextSpanBuilder::new("<CTRL+S>")
|
||||
.bold()
|
||||
.with_foreground(Color::Cyan)
|
||||
.build(),
|
||||
)
|
||||
.add_col(TextSpan::from(" Save configuration"))
|
||||
.build(),
|
||||
))
|
||||
.build(),
|
||||
)),
|
||||
);
|
||||
// Active help
|
||||
self.view.active(super::COMPONENT_TEXT_HELP);
|
||||
}
|
||||
|
||||
/// ### umount_help
|
||||
///
|
||||
/// Umount help
|
||||
pub(super) fn umount_help(&mut self) {
|
||||
self.view.umount(super::COMPONENT_TEXT_HELP);
|
||||
}
|
||||
|
||||
/// ### load_input_values
|
||||
///
|
||||
/// Load values from configuration into input fields
|
||||
pub(super) fn load_input_values(&mut self) {
|
||||
if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
// Text editor
|
||||
if let Some(props) = self
|
||||
.view
|
||||
.get_props(super::COMPONENT_INPUT_TEXT_EDITOR)
|
||||
.as_mut()
|
||||
{
|
||||
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 _ = 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()
|
||||
{
|
||||
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 _ = 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()
|
||||
{
|
||||
let hidden: usize = match cli.get_show_hidden_files() {
|
||||
true => 0,
|
||||
false => 1,
|
||||
};
|
||||
let props = props.with_value(PropValue::Unsigned(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() {
|
||||
let updates: usize = match cli.get_check_for_updates() {
|
||||
true => 0,
|
||||
false => 1,
|
||||
};
|
||||
let props = props.with_value(PropValue::Unsigned(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()
|
||||
{
|
||||
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 _ = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### collect_input_values
|
||||
///
|
||||
/// Collect values from input and put them into the configuration
|
||||
pub(super) fn collect_input_values(&mut self) {
|
||||
if let Some(cli) = self.context.as_mut().unwrap().config_client.as_mut() {
|
||||
if let Some(Payload::Text(editor)) =
|
||||
self.view.get_value(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)
|
||||
{
|
||||
let protocol: FileTransferProtocol = match protocol {
|
||||
1 => FileTransferProtocol::Scp,
|
||||
2 => FileTransferProtocol::Ftp(false),
|
||||
3 => FileTransferProtocol::Ftp(true),
|
||||
_ => FileTransferProtocol::Sftp,
|
||||
};
|
||||
cli.set_default_protocol(protocol);
|
||||
}
|
||||
if let Some(Payload::Unsigned(opt)) =
|
||||
self.view.get_value(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)
|
||||
{
|
||||
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::Unsigned(opt)) =
|
||||
self.view.get_value(super::COMPONENT_RADIO_GROUP_DIRS)
|
||||
{
|
||||
let dirs: Option<GroupDirs> = match opt {
|
||||
0 => Some(GroupDirs::First),
|
||||
1 => Some(GroupDirs::Last),
|
||||
_ => None,
|
||||
};
|
||||
cli.set_group_dirs(dirs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ### reload_ssh_keys
|
||||
///
|
||||
/// Reload ssh keys
|
||||
pub(super) fn reload_ssh_keys(&mut self) {
|
||||
if let Some(cli) = self.context.as_ref().unwrap().config_client.as_ref() {
|
||||
// get props
|
||||
if let Some(props) = self.view.get_props(super::COMPONENT_LIST_SSH_KEYS).as_mut() {
|
||||
// Create texts
|
||||
let keys: Vec<TextSpan> = 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())
|
||||
})
|
||||
.collect();
|
||||
let props = props
|
||||
.with_texts(TextParts::new(Some(String::from("SSH Keys")), Some(keys)))
|
||||
.build();
|
||||
self.view.update(super::COMPONENT_LIST_SSH_KEYS, props);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,40 +2,46 @@
|
||||
//!
|
||||
//! `Context` is the module which provides all the functionalities related to the UI data holder, called Context
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// Dependencies
|
||||
extern crate crossterm;
|
||||
extern crate tui;
|
||||
|
||||
// Locals
|
||||
use super::input::InputHandler;
|
||||
use super::store::Store;
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::host::Localhost;
|
||||
use crate::system::config_client::ConfigClient;
|
||||
|
||||
// Includes
|
||||
use crossterm::execute;
|
||||
use crossterm::event::DisableMouseCapture;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use std::io::{stdout, Stdout, Write};
|
||||
use std::io::{stdout, Stdout};
|
||||
use std::path::PathBuf;
|
||||
use tui::backend::CrosstermBackend;
|
||||
use tui::Terminal;
|
||||
|
||||
@@ -44,24 +50,93 @@ use tui::Terminal;
|
||||
/// 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,
|
||||
pub(crate) input_hnd: InputHandler,
|
||||
pub(crate) terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// ### FileTransferParams
|
||||
///
|
||||
/// Holds connection parameters for file transfers
|
||||
pub struct FileTransferParams {
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub protocol: FileTransferProtocol,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub entry_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiates a new Context
|
||||
pub fn new(local: Localhost) -> Context {
|
||||
pub fn new(
|
||||
local: Localhost,
|
||||
config_client: Option<ConfigClient>,
|
||||
error: Option<String>,
|
||||
) -> Context {
|
||||
// Create terminal
|
||||
let mut stdout = stdout();
|
||||
assert!(execute!(stdout, EnterAlternateScreen).is_ok());
|
||||
Context {
|
||||
local: local,
|
||||
local,
|
||||
ft_params: None,
|
||||
config_client,
|
||||
store: Store::init(),
|
||||
input_hnd: InputHandler::new(),
|
||||
terminal: Terminal::new(CrosstermBackend::new(stdout)).unwrap()
|
||||
terminal: Terminal::new(CrosstermBackend::new(stdout)).unwrap(),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE: in case is necessary
|
||||
/// ### set_error
|
||||
///
|
||||
/// Set context error
|
||||
pub fn set_error(&mut self, err: String) {
|
||||
self.error = Some(err);
|
||||
}
|
||||
*/
|
||||
|
||||
/// ### get_error
|
||||
///
|
||||
/// Get error message and remove it from the context
|
||||
pub fn get_error(&mut self) -> Option<String> {
|
||||
self.error.take()
|
||||
}
|
||||
|
||||
/// ### enter_alternate_screen
|
||||
///
|
||||
/// Enter alternate screen (gui window)
|
||||
pub fn enter_alternate_screen(&mut self) {
|
||||
let _ = execute!(
|
||||
self.terminal.backend_mut(),
|
||||
EnterAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
}
|
||||
|
||||
/// ### leave_alternate_screen
|
||||
///
|
||||
/// Go back to normal screen (gui window)
|
||||
pub fn leave_alternate_screen(&mut self) {
|
||||
let _ = execute!(
|
||||
self.terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
}
|
||||
|
||||
/// ### clear_screen
|
||||
///
|
||||
/// Clear terminal screen
|
||||
pub fn clear_screen(&mut self) {
|
||||
let _ = self.terminal.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Context {
|
||||
@@ -72,18 +147,41 @@ impl Drop for Context {
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
drop(self);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
impl Default for FileTransferParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
address: String::new(),
|
||||
port: 22,
|
||||
protocol: FileTransferProtocol::Sftp,
|
||||
username: None,
|
||||
password: None,
|
||||
entry_directory: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::filetransfer::sftp_transfer::SftpFileTransfer;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_ui_context_ft_params() {
|
||||
let params: FileTransferParams = FileTransferParams::default();
|
||||
assert_eq!(params.address.as_str(), "");
|
||||
assert_eq!(params.port, 22);
|
||||
assert_eq!(params.protocol, FileTransferProtocol::Sftp);
|
||||
assert!(params.username.is_none());
|
||||
assert!(params.password.is_none());
|
||||
}
|
||||
|
||||
//use crate::filetransfer::sftp_transfer::SftpFileTransfer;
|
||||
//use std::path::PathBuf;
|
||||
|
||||
/*
|
||||
#[test]
|
||||
fn test_ui_context_new() {
|
||||
// Prepare stuff
|
||||
@@ -106,5 +204,5 @@ mod tests {
|
||||
.is_ok());
|
||||
Box::new(sftp_client)
|
||||
}
|
||||
*/
|
||||
}
|
||||
*/
|
||||
@@ -2,27 +2,29 @@
|
||||
//!
|
||||
//! `input` is the module which provides all the functionalities related to input events in the user interface
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 crossterm;
|
||||
|
||||
use crossterm::event::{poll, read, Event};
|
||||
@@ -32,7 +34,7 @@ use std::time::Duration;
|
||||
///
|
||||
/// InputHandler is the struct which runs a thread which waits for
|
||||
/// input events from the user and reports them through a receiver
|
||||
pub(crate) struct InputHandler {}
|
||||
pub(crate) struct InputHandler;
|
||||
|
||||
impl InputHandler {
|
||||
/// ### InputHandler
|
||||
@@ -45,22 +47,23 @@ impl InputHandler {
|
||||
/// ### fetch_events
|
||||
///
|
||||
/// Check if new events have been received from handler
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn fetch_events(&self) -> Result<Vec<Event>, ()> {
|
||||
let mut inbox: Vec<Event> = Vec::new();
|
||||
loop {
|
||||
match self.read_event() {
|
||||
Ok(ev_opt) => match ev_opt {
|
||||
Some(ev) => inbox.push(ev),
|
||||
None => break
|
||||
None => break,
|
||||
},
|
||||
Err(_) => return Err(())
|
||||
Err(_) => return Err(()),
|
||||
}
|
||||
}
|
||||
Ok(inbox)
|
||||
}
|
||||
|
||||
/// ### read_event
|
||||
///
|
||||
///
|
||||
/// Read event from input listener
|
||||
pub(crate) fn read_event(&self) -> Result<Option<Event>, ()> {
|
||||
if let Ok(available) = poll(Duration::from_millis(10)) {
|
||||
@@ -73,7 +76,7 @@ impl InputHandler {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
false => Ok(None)
|
||||
false => Ok(None),
|
||||
}
|
||||
} else {
|
||||
Err(())
|
||||
|
||||
373
src/ui/layout/components/bookmark_list.rs
Normal file
@@ -0,0 +1,373 @@
|
||||
//! ## Bookmark list
|
||||
//!
|
||||
//! `BookmarkList` component renders a bookmark 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### reset_list_index
|
||||
///
|
||||
/// Reset list index to 0
|
||||
pub fn reset_list_index(&mut self) {
|
||||
self.list_index = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Component
|
||||
|
||||
/// ## BookmarkList
|
||||
///
|
||||
/// Bookmark list component
|
||||
pub struct BookmarkList {
|
||||
props: Props,
|
||||
states: OwnStates,
|
||||
}
|
||||
|
||||
impl BookmarkList {
|
||||
/// ### 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,
|
||||
});
|
||||
BookmarkList { props, states }
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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 => (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),
|
||||
)
|
||||
.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,
|
||||
});
|
||||
// Reset list index
|
||||
self.states.reset_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_bookmarks_list() {
|
||||
// Make component
|
||||
let mut component: BookmarkList = BookmarkList::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, 0);
|
||||
assert_eq!(component.states.list_len, 3);
|
||||
// get value
|
||||
assert_eq!(component.get_value(), Payload::Unsigned(0));
|
||||
// Render
|
||||
assert_eq!(component.states.list_index, 0);
|
||||
// Handle inputs
|
||||
assert_eq!(
|
||||
component.on(InputEvent::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))),
|
||||
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))),
|
||||
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))
|
||||
);
|
||||
}
|
||||
}
|
||||
377
src/ui/layout/components/file_list.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
//! ## 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
564
src/ui/layout/components/input.rs
Normal file
@@ -0,0 +1,564 @@
|
||||
//! ## 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);
|
||||
}
|
||||
}
|
||||
401
src/ui/layout/components/logbox.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
//! ## LogBox
|
||||
//!
|
||||
//! `LogBox` component renders a log box view
|
||||
|
||||
/**
|
||||
* 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 std::collections::VecDeque;
|
||||
use tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::Style,
|
||||
text::{Span, Spans},
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// ### reset_list_index
|
||||
///
|
||||
/// Reset list index to last element
|
||||
pub fn reset_list_index(&mut self) {
|
||||
self.list_index = 0; // Last element is always 0
|
||||
}
|
||||
}
|
||||
|
||||
// -- Component
|
||||
|
||||
/// ## LogBox
|
||||
///
|
||||
/// LogBox list component
|
||||
pub struct LogBox {
|
||||
props: Props,
|
||||
states: OwnStates,
|
||||
}
|
||||
|
||||
impl LogBox {
|
||||
/// ### 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.table {
|
||||
Some(rows) => rows.len(),
|
||||
None => 0,
|
||||
});
|
||||
// Reset list index
|
||||
states.reset_list_index();
|
||||
LogBox { props, states }
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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)
|
||||
})
|
||||
.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),
|
||||
)
|
||||
.start_corner(Corner::BottomLeft)
|
||||
.highlight_symbol(">> ")
|
||||
.highlight_style(Style::default().add_modifier(self.props.get_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
|
||||
self.states.set_list_len(match &self.props.texts.table {
|
||||
Some(rows) => rows.len(),
|
||||
None => 0,
|
||||
});
|
||||
// Reset list index
|
||||
self.states.reset_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::Up => {
|
||||
// Update states
|
||||
self.states.incr_list_index();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Update states
|
||||
self.states.decr_list_index();
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
// Update states
|
||||
for _ in 0..8 {
|
||||
self.states.incr_list_index();
|
||||
}
|
||||
Msg::None
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
// Update states
|
||||
for _ in 0..8 {
|
||||
self.states.decr_list_index();
|
||||
}
|
||||
Msg::None
|
||||
}
|
||||
_ => {
|
||||
// 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::{TableBuilder, TextParts, TextSpan};
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use tui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_components_logbox() {
|
||||
let mut component: LogBox = LogBox::new(
|
||||
PropsBuilder::default()
|
||||
.with_texts(TextParts::table(
|
||||
Some(String::from("Log")),
|
||||
TableBuilder::default()
|
||||
.add_col(TextSpan::from("12:29"))
|
||||
.add_col(TextSpan::from("system crashed"))
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("12:38"))
|
||||
.add_col(TextSpan::from("system alive"))
|
||||
.build(),
|
||||
))
|
||||
.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::table(
|
||||
Some(String::from("Log")),
|
||||
TableBuilder::default()
|
||||
.add_col(TextSpan::from("12:29"))
|
||||
.add_col(TextSpan::from("system crashed"))
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("12:38"))
|
||||
.add_col(TextSpan::from("system alive"))
|
||||
.add_row()
|
||||
.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));
|
||||
// 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))),
|
||||
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))),
|
||||
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))),
|
||||
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))),
|
||||
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))),
|
||||
Msg::OnKey(KeyEvent::from(KeyCode::Backspace))
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/ui/layout/components/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
//! ## Components
|
||||
//!
|
||||
//! `Components` is the module which contains the definitions for all the GUI components 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.
|
||||
*/
|
||||
// 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;
|
||||
214
src/ui/layout/components/msgbox.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
//! ## MsgBox
|
||||
//!
|
||||
//! `MsgBox` component renders a simple readonly no event associated centered 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.
|
||||
*/
|
||||
// deps
|
||||
extern crate textwrap;
|
||||
// locals
|
||||
use super::{Canvas, Component, InputEvent, Msg, Payload, Props, PropsBuilder};
|
||||
use crate::utils::fmt::align_text_center;
|
||||
// ext
|
||||
use tui::{
|
||||
layout::{Corner, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Span, Spans},
|
||||
widgets::{Block, BorderType, List, ListItem},
|
||||
};
|
||||
|
||||
// -- component
|
||||
|
||||
pub struct MsgBox {
|
||||
props: Props,
|
||||
}
|
||||
|
||||
impl MsgBox {
|
||||
/// ### new
|
||||
///
|
||||
/// Instantiate a new Text component
|
||||
pub fn new(props: Props) -> Self {
|
||||
MsgBox { props }
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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 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),
|
||||
)])));
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
.start_corner(Corner::TopLeft)
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(self.props.foreground)
|
||||
.bg(self.props.background),
|
||||
),
|
||||
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_msgbox() {
|
||||
let mut component: MsgBox = MsgBox::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))
|
||||
);
|
||||
}
|
||||
}
|
||||
184
src/ui/layout/components/progress_bar.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! ## 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
334
src/ui/layout/components/radio_group.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
//! ## 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'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
202
src/ui/layout/components/table.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! ## 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
195
src/ui/layout/components/text.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
//! ## 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
159
src/ui/layout/components/title.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
//! ## 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
122
src/ui/layout/mod.rs
Normal file
@@ -0,0 +1,122 @@
|
||||
//! ## 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);
|
||||
}
|
||||
779
src/ui/layout/props.rs
Normal file
@@ -0,0 +1,779 @@
|
||||
//! ## 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));
|
||||
}
|
||||
}
|
||||
72
src/ui/layout/utils.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! ## Utils
|
||||
//!
|
||||
//! `Utils` implements utilities functions to work with 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.
|
||||
*/
|
||||
use tui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// ### draw_area_in
|
||||
///
|
||||
/// Draw an area (WxH / 3) in the middle of the parent area
|
||||
pub fn draw_area_in(parent: Rect, width: u16, height: u16) -> Rect {
|
||||
let new_area = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - height) / 2),
|
||||
Constraint::Percentage(height),
|
||||
Constraint::Percentage((100 - height) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(parent);
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Percentage((100 - width) / 2),
|
||||
Constraint::Percentage(width),
|
||||
Constraint::Percentage((100 - width) / 2),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.split(new_area[1])[1]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ui_layout_utils_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);
|
||||
assert_eq!(child.y, 63);
|
||||
assert_eq!(child.width, 271);
|
||||
assert_eq!(child.height, 54);
|
||||
}
|
||||
}
|
||||
461
src/ui/layout/view.rs
Normal file
@@ -0,0 +1,461 @@
|
||||
//! ## 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,32 @@
|
||||
//!
|
||||
//! `ui` is the module which provides all the functionalities related to the UI
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 activities;
|
||||
pub mod context;
|
||||
pub(crate) mod input;
|
||||
pub(crate) mod layout;
|
||||
pub(crate) mod store;
|
||||
|
||||
210
src/ui/store.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! ## Store
|
||||
//!
|
||||
//! `Store` is the module which provides the Context Storage.
|
||||
//! The context storage is a storage indeed which is shared between the activities thanks to the context
|
||||
//! The storage can be used to store any values which should be cached or shared between activities
|
||||
|
||||
/**
|
||||
* 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::collections::HashMap;
|
||||
|
||||
// -- store state
|
||||
|
||||
/// ## StoreState
|
||||
///
|
||||
/// Store state describes a value in the store
|
||||
#[allow(dead_code)]
|
||||
enum StoreState {
|
||||
Str(String), // String
|
||||
Signed(isize), // Signed number
|
||||
Unsigned(usize), // Unsigned number
|
||||
Float(f64), // Floating point number
|
||||
Boolean(bool), // Boolean value
|
||||
Flag, // Empty value; used to work as a Flag (set unset)
|
||||
}
|
||||
|
||||
// -- store
|
||||
|
||||
/// ## Store
|
||||
///
|
||||
/// Store represent the context store
|
||||
/// The store is a key-value hash map. Each key must be unique
|
||||
/// To each key a `StoreState` is assigned
|
||||
pub(crate) struct Store {
|
||||
store: HashMap<String, StoreState>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Store {
|
||||
/// ### init
|
||||
///
|
||||
/// Initialize a new Store
|
||||
pub fn init() -> Self {
|
||||
Store {
|
||||
store: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// -- getters
|
||||
/// ### get_string
|
||||
///
|
||||
/// Get string from store
|
||||
pub fn get_string(&self, key: &str) -> Option<&str> {
|
||||
match self.store.get(key) {
|
||||
Some(StoreState::Str(s)) => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_signed
|
||||
///
|
||||
/// Get signed from store
|
||||
pub fn get_signed(&self, key: &str) -> Option<isize> {
|
||||
match self.store.get(key) {
|
||||
Some(StoreState::Signed(i)) => Some(*i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_unsigned
|
||||
///
|
||||
/// Get unsigned from store
|
||||
pub fn get_unsigned(&self, key: &str) -> Option<usize> {
|
||||
match self.store.get(key) {
|
||||
Some(StoreState::Unsigned(u)) => Some(*u),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_float
|
||||
///
|
||||
/// get float from store
|
||||
pub fn get_float(&self, key: &str) -> Option<f64> {
|
||||
match self.store.get(key) {
|
||||
Some(StoreState::Float(f)) => Some(*f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### get_boolean
|
||||
///
|
||||
/// get boolean from store
|
||||
pub fn get_boolean(&self, key: &str) -> Option<bool> {
|
||||
match self.store.get(key) {
|
||||
Some(StoreState::Boolean(b)) => Some(*b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ### isset
|
||||
///
|
||||
/// Check if a state is set in the store
|
||||
pub fn isset(&self, key: &str) -> bool {
|
||||
self.store.get(key).is_some()
|
||||
}
|
||||
|
||||
// -- setters
|
||||
|
||||
/// ### set_string
|
||||
///
|
||||
/// Set string into the store
|
||||
pub fn set_string(&mut self, key: &str, val: String) {
|
||||
self.store.insert(key.to_string(), StoreState::Str(val));
|
||||
}
|
||||
|
||||
/// ### set_signed
|
||||
///
|
||||
/// Set signed number
|
||||
pub fn set_signed(&mut self, key: &str, val: isize) {
|
||||
self.store.insert(key.to_string(), StoreState::Signed(val));
|
||||
}
|
||||
|
||||
/// ### set_signed
|
||||
///
|
||||
/// Set unsigned number
|
||||
pub fn set_unsigned(&mut self, key: &str, val: usize) {
|
||||
self.store
|
||||
.insert(key.to_string(), StoreState::Unsigned(val));
|
||||
}
|
||||
|
||||
/// ### set_float
|
||||
///
|
||||
/// Set floating point number
|
||||
pub fn set_float(&mut self, key: &str, val: f64) {
|
||||
self.store.insert(key.to_string(), StoreState::Float(val));
|
||||
}
|
||||
|
||||
/// ### set_boolean
|
||||
///
|
||||
/// Set boolean
|
||||
pub fn set_boolean(&mut self, key: &str, val: bool) {
|
||||
self.store.insert(key.to_string(), StoreState::Boolean(val));
|
||||
}
|
||||
|
||||
/// ### set
|
||||
///
|
||||
/// Set a key as a flag; has no value
|
||||
pub fn set(&mut self, key: &str) {
|
||||
self.store.insert(key.to_string(), StoreState::Flag);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_ui_store() {
|
||||
// Create store
|
||||
let mut store: Store = Store::init();
|
||||
// Test string
|
||||
store.set_string("test", String::from("hello"));
|
||||
assert_eq!(*store.get_string("test").as_ref().unwrap(), "hello");
|
||||
// Test isize
|
||||
store.set_signed("number", 3005);
|
||||
assert_eq!(store.get_signed("number").unwrap(), 3005);
|
||||
store.set_signed("number", -123);
|
||||
assert_eq!(store.get_signed("number").unwrap(), -123);
|
||||
// Test usize
|
||||
store.set_unsigned("unumber", 1024);
|
||||
assert_eq!(store.get_unsigned("unumber").unwrap(), 1024);
|
||||
// Test float
|
||||
store.set_float("float", 3.33);
|
||||
assert_eq!(store.get_float("float").unwrap(), 3.33);
|
||||
// Test boolean
|
||||
store.set_boolean("bool", true);
|
||||
assert_eq!(store.get_boolean("bool").unwrap(), true);
|
||||
// Test flag
|
||||
store.set("myflag");
|
||||
assert_eq!(store.isset("myflag"), true);
|
||||
// Test unexisting
|
||||
assert!(store.get_boolean("unexisting-key").is_none());
|
||||
assert!(store.get_float("unexisting-key").is_none());
|
||||
assert!(store.get_signed("unexisting-key").is_none());
|
||||
assert!(store.get_signed("unexisting-key").is_none());
|
||||
assert!(store.get_string("unexisting-key").is_none());
|
||||
assert!(store.get_unsigned("unexisting-key").is_none());
|
||||
}
|
||||
}
|
||||
395
src/utils.rs
@@ -1,395 +0,0 @@
|
||||
//! ## Utils
|
||||
//!
|
||||
//! `utils` is the module which provides utilities of different kind
|
||||
|
||||
/*
|
||||
*
|
||||
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
|
||||
*
|
||||
* This file is part of "TermSCP"
|
||||
*
|
||||
* TermSCP is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* TermSCP is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
// Dependencies
|
||||
extern crate chrono;
|
||||
extern crate whoami;
|
||||
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
|
||||
use chrono::format::ParseError;
|
||||
use chrono::prelude::*;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// ### parse_remote_opt
|
||||
///
|
||||
/// Parse remote option string. Returns in case of success a tuple made of (address, port, protocol, username)
|
||||
/// For ssh if username is not provided, current user will be used.
|
||||
/// In case of error, message is returned
|
||||
/// If port is missing default port will be used for each protocol
|
||||
/// SFTP => 22
|
||||
/// FTP => 21
|
||||
/// The option string has the following syntax
|
||||
/// [protocol]://[username]@{address}:[port]
|
||||
/// The only argument which is mandatory is address
|
||||
/// NOTE: possible strings
|
||||
/// - 172.26.104.1
|
||||
/// - root@172.26.104.1
|
||||
/// - sftp://root@172.26.104.1
|
||||
/// - sftp://172.26.104.1:4022
|
||||
/// - sftp://172.26.104.1
|
||||
/// - ...
|
||||
///
|
||||
pub fn parse_remote_opt(
|
||||
remote: &String,
|
||||
) -> Result<(String, u16, FileTransferProtocol, Option<String>), String> {
|
||||
let mut wrkstr: String = remote.clone();
|
||||
let address: String;
|
||||
let mut port: u16 = 22;
|
||||
let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp;
|
||||
let mut username: Option<String> = None;
|
||||
// Split string by '://'
|
||||
let tokens: Vec<&str> = wrkstr.split("://").collect();
|
||||
// If length is > 1, then token[0] is protocol
|
||||
match tokens.len() {
|
||||
1 => {}
|
||||
2 => {
|
||||
// Parse protocol
|
||||
match tokens[0] {
|
||||
"sftp" => {
|
||||
// Set protocol to sftp
|
||||
protocol = FileTransferProtocol::Sftp;
|
||||
// Set port to default (22)
|
||||
port = 22;
|
||||
}
|
||||
"scp" => {
|
||||
// Set protocol to scp
|
||||
protocol = FileTransferProtocol::Scp;
|
||||
// Set port to default (22)
|
||||
port = 22;
|
||||
}
|
||||
"ftp" => {
|
||||
// Set protocol to fpt
|
||||
protocol = FileTransferProtocol::Ftp(false);
|
||||
// Set port to default (21)
|
||||
port = 21;
|
||||
}
|
||||
"ftps" => {
|
||||
// Set protocol to fpt
|
||||
protocol = FileTransferProtocol::Ftp(true);
|
||||
// Set port to default (21)
|
||||
port = 21;
|
||||
}
|
||||
_ => return Err(format!("Unknown protocol '{}'", tokens[0])),
|
||||
}
|
||||
wrkstr = String::from(tokens[1]); // Wrkstr becomes tokens[1]
|
||||
}
|
||||
_ => return Err(String::from("Bad syntax")), // Too many tokens...
|
||||
}
|
||||
// Set username to default if sftp
|
||||
if protocol == FileTransferProtocol::Sftp {
|
||||
// Set username to current username
|
||||
username = Some(whoami::username());
|
||||
}
|
||||
// Split wrkstring by '@'
|
||||
let tokens: Vec<&str> = wrkstr.split("@").collect();
|
||||
match tokens.len() {
|
||||
1 => {}
|
||||
2 => {
|
||||
// Username is first token
|
||||
username = Some(String::from(tokens[0]));
|
||||
// Update wrkstr
|
||||
wrkstr = String::from(tokens[1]);
|
||||
}
|
||||
_ => return Err(String::from("Bad syntax")), // Too many tokens...
|
||||
}
|
||||
// Split wrkstring by ':'
|
||||
let tokens: Vec<&str> = wrkstr.split(":").collect();
|
||||
match tokens.len() {
|
||||
1 => {
|
||||
// Address is wrkstr
|
||||
address = wrkstr.clone();
|
||||
}
|
||||
2 => {
|
||||
// Address is first token
|
||||
address = String::from(tokens[0]);
|
||||
// Port is second str
|
||||
port = match tokens[1].parse::<u16>() {
|
||||
Ok(val) => val,
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"Port must be a number in range [0-65535], but is '{}'",
|
||||
tokens[1]
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => return Err(String::from("Bad syntax")), // Too many tokens...
|
||||
}
|
||||
Ok((address, port, protocol, username))
|
||||
}
|
||||
|
||||
/// ### fmt_pex
|
||||
///
|
||||
/// Convert 3 bytes of permissions value into ls notation (e.g. rwx-wx--x)
|
||||
pub fn fmt_pex(owner: u8, group: u8, others: u8) -> String {
|
||||
let mut mode: String = String::with_capacity(9);
|
||||
let read: u8 = (owner >> 2) & 0x1;
|
||||
let write: u8 = (owner >> 1) & 0x1;
|
||||
let exec: u8 = owner & 0x1;
|
||||
mode.push_str(match read {
|
||||
1 => "r",
|
||||
_ => "-",
|
||||
});
|
||||
mode.push_str(match write {
|
||||
1 => "w",
|
||||
_ => "-",
|
||||
});
|
||||
mode.push_str(match exec {
|
||||
1 => "x",
|
||||
_ => "-",
|
||||
});
|
||||
let read: u8 = (group >> 2) & 0x1;
|
||||
let write: u8 = (group >> 1) & 0x1;
|
||||
let exec: u8 = group & 0x1;
|
||||
mode.push_str(match read {
|
||||
1 => "r",
|
||||
_ => "-",
|
||||
});
|
||||
mode.push_str(match write {
|
||||
1 => "w",
|
||||
_ => "-",
|
||||
});
|
||||
mode.push_str(match exec {
|
||||
1 => "x",
|
||||
_ => "-",
|
||||
});
|
||||
let read: u8 = (others >> 2) & 0x1;
|
||||
let write: u8 = (others >> 1) & 0x1;
|
||||
let exec: u8 = others & 0x1;
|
||||
mode.push_str(match read {
|
||||
1 => "r",
|
||||
_ => "-",
|
||||
});
|
||||
mode.push_str(match write {
|
||||
1 => "w",
|
||||
_ => "-",
|
||||
});
|
||||
mode.push_str(match exec {
|
||||
1 => "x",
|
||||
_ => "-",
|
||||
});
|
||||
mode
|
||||
}
|
||||
|
||||
/// ### instant_to_str
|
||||
///
|
||||
/// Format a `Instant` into a time string
|
||||
pub fn time_to_str(time: SystemTime, fmt: &str) -> String {
|
||||
let datetime: DateTime<Local> = time.into();
|
||||
format!("{}", datetime.format(fmt))
|
||||
}
|
||||
|
||||
/// ### lstime_to_systime
|
||||
///
|
||||
/// Convert ls syntax time to System Time
|
||||
/// ls time has two possible syntax:
|
||||
/// 1. if year is current: %b %d %H:%M (e.g. Nov 5 13:46)
|
||||
/// 2. else: %b %d %Y (e.g. Nov 5 2019)
|
||||
pub fn lstime_to_systime(
|
||||
tm: &str,
|
||||
fmt_year: &str,
|
||||
fmt_hours: &str,
|
||||
) -> Result<SystemTime, ParseError> {
|
||||
let datetime: NaiveDateTime = match NaiveDate::parse_from_str(tm, fmt_year) {
|
||||
Ok(date) => {
|
||||
// Case 2.
|
||||
// Return NaiveDateTime from NaiveDate with time 00:00:00
|
||||
date.and_hms(0, 0, 0)
|
||||
}
|
||||
Err(_) => {
|
||||
// Might be case 1.
|
||||
// We need to add Current Year at the end of the string
|
||||
let this_year: i32 = Utc::now().year();
|
||||
let date_time_str: String = format!("{} {}", tm, this_year);
|
||||
// Now parse
|
||||
match NaiveDateTime::parse_from_str(
|
||||
date_time_str.as_ref(),
|
||||
format!("{} %Y", fmt_hours).as_ref(),
|
||||
) {
|
||||
Ok(dt) => dt,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
};
|
||||
// Convert datetime to system time
|
||||
let sys_time: SystemTime = SystemTime::UNIX_EPOCH;
|
||||
Ok(sys_time
|
||||
.checked_add(Duration::from_secs(datetime.timestamp() as u64))
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_utils_parse_remote_opt() {
|
||||
// Base case
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("172.26.104.1"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 22);
|
||||
assert_eq!(result.2, FileTransferProtocol::Sftp);
|
||||
assert!(result.3.is_some());
|
||||
// User case
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("root@172.26.104.1"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 22);
|
||||
assert_eq!(result.2, FileTransferProtocol::Sftp);
|
||||
assert_eq!(result.3.unwrap(), String::from("root"));
|
||||
// User + port
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("root@172.26.104.1:8022"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 8022);
|
||||
assert_eq!(result.2, FileTransferProtocol::Sftp);
|
||||
assert_eq!(result.3.unwrap(), String::from("root"));
|
||||
// Port only
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("172.26.104.1:4022"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 4022);
|
||||
assert_eq!(result.2, FileTransferProtocol::Sftp);
|
||||
assert!(result.3.is_some());
|
||||
// Protocol
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("ftp://172.26.104.1"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 21); // Fallback to ftp default
|
||||
assert_eq!(result.2, FileTransferProtocol::Ftp(false));
|
||||
assert!(result.3.is_none()); // Doesn't fall back
|
||||
// Protocol
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("scp://172.26.104.1"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 22); // Fallback to scp default
|
||||
assert_eq!(result.2, FileTransferProtocol::Scp);
|
||||
assert!(result.3.is_none()); // Doesn't fall back
|
||||
// Protocol + user
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("ftps://anon@172.26.104.1"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 21); // Fallback to ftp default
|
||||
assert_eq!(result.2, FileTransferProtocol::Ftp(true));
|
||||
assert_eq!(result.3.unwrap(), String::from("anon"));
|
||||
// All together now
|
||||
let result: (String, u16, FileTransferProtocol, Option<String>) =
|
||||
parse_remote_opt(&String::from("ftp://anon@172.26.104.1:8021"))
|
||||
.ok()
|
||||
.unwrap();
|
||||
assert_eq!(result.0, String::from("172.26.104.1"));
|
||||
assert_eq!(result.1, 8021); // Fallback to ftp default
|
||||
assert_eq!(result.2, FileTransferProtocol::Ftp(false));
|
||||
assert_eq!(result.3.unwrap(), String::from("anon"));
|
||||
|
||||
// bad syntax
|
||||
assert!(parse_remote_opt(&String::from("://172.26.104.1")).is_err()); // Missing protocol
|
||||
assert!(parse_remote_opt(&String::from("omar://172.26.104.1")).is_err()); // Bad protocol
|
||||
assert!(parse_remote_opt(&String::from("172.26.104.1:abc")).is_err()); // Bad port
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utils_fmt_pex() {
|
||||
assert_eq!(fmt_pex(7, 7, 7), String::from("rwxrwxrwx"));
|
||||
assert_eq!(fmt_pex(7, 5, 5), String::from("rwxr-xr-x"));
|
||||
assert_eq!(fmt_pex(6, 6, 6), String::from("rw-rw-rw-"));
|
||||
assert_eq!(fmt_pex(6, 4, 4), String::from("rw-r--r--"));
|
||||
assert_eq!(fmt_pex(6, 0, 0), String::from("rw-------"));
|
||||
assert_eq!(fmt_pex(0, 0, 0), String::from("---------"));
|
||||
assert_eq!(fmt_pex(4, 4, 4), String::from("r--r--r--"));
|
||||
assert_eq!(fmt_pex(1, 2, 1), String::from("--x-w---x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utils_time_to_str() {
|
||||
let system_time: SystemTime = SystemTime::from(SystemTime::UNIX_EPOCH);
|
||||
assert_eq!(
|
||||
time_to_str(system_time, "%Y-%m-%d"),
|
||||
String::from("1970-01-01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_utils_lstime_to_systime() {
|
||||
// Good cases
|
||||
assert_eq!(
|
||||
lstime_to_systime("Nov 5 16:32", "%b %d %Y", "%b %d %H:%M")
|
||||
.ok()
|
||||
.unwrap()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.unwrap(),
|
||||
Duration::from_secs(1604593920)
|
||||
);
|
||||
assert_eq!(
|
||||
lstime_to_systime("Dec 2 21:32", "%b %d %Y", "%b %d %H:%M")
|
||||
.ok()
|
||||
.unwrap()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.unwrap(),
|
||||
Duration::from_secs(1606944720)
|
||||
);
|
||||
assert_eq!(
|
||||
lstime_to_systime("Nov 5 2018", "%b %d %Y", "%b %d %H:%M")
|
||||
.ok()
|
||||
.unwrap()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.unwrap(),
|
||||
Duration::from_secs(1541376000)
|
||||
);
|
||||
assert_eq!(
|
||||
lstime_to_systime("Mar 18 2018", "%b %d %Y", "%b %d %H:%M")
|
||||
.ok()
|
||||
.unwrap()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.ok()
|
||||
.unwrap(),
|
||||
Duration::from_secs(1521331200)
|
||||
);
|
||||
// bad cases
|
||||
assert!(lstime_to_systime("Oma 31 2018", "%b %d %Y", "%b %d %H:%M").is_err());
|
||||
assert!(lstime_to_systime("Feb 31 2018", "%b %d %Y", "%b %d %H:%M").is_err());
|
||||
assert!(lstime_to_systime("Feb 15 25:32", "%b %d %Y", "%b %d %H:%M").is_err());
|
||||
}
|
||||
}
|
||||