mirror of
https://github.com/veeso/termscp.git
synced 2025-12-06 17:15:35 -08:00
Compare commits
15 Commits
v0.17.0
...
4bebec369f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bebec369f | ||
|
|
05c8613279 | ||
|
|
205d2813ad | ||
|
|
86660a0cc9 | ||
|
|
3c79e812eb | ||
|
|
0287e7706a | ||
|
|
67a14c2725 | ||
|
|
df03c5c1bf | ||
|
|
3ce3ffee3d | ||
|
|
c0b32a1847 | ||
|
|
81ae0035c3 | ||
|
|
783da22ca2 | ||
|
|
8715c2b6f9 | ||
|
|
98a748dccc | ||
|
|
bef031a414 |
41
.github/workflows/build-artifacts.yml
vendored
41
.github/workflows/build-artifacts.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TERMSCP_VERSION: "0.17.0"
|
||||
TERMSCP_VERSION: "0.19.0"
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
@@ -29,8 +29,45 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform.target }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
brew update
|
||||
brew install \
|
||||
bison \
|
||||
cpanminus \
|
||||
cups \
|
||||
flex \
|
||||
gettext \
|
||||
gmp \
|
||||
gnutls \
|
||||
icu4c \
|
||||
jansson \
|
||||
libarchive \
|
||||
libbsd \
|
||||
libunistring \
|
||||
libgit2 \
|
||||
libtirpc \
|
||||
openldap \
|
||||
pkg-config \
|
||||
zlib
|
||||
brew link --force bison
|
||||
brew link --force cups
|
||||
brew link --force flex
|
||||
brew link --force gettext
|
||||
brew link --force gmp
|
||||
brew link --force gnutls
|
||||
brew link --force icu4c
|
||||
brew link --force jansson
|
||||
brew link --force libarchive
|
||||
brew link --force libbsd
|
||||
brew link --force libgit2
|
||||
brew link --force libtirpc
|
||||
brew link --force libunistring
|
||||
brew link --force openldap
|
||||
brew link --force zlib
|
||||
cpanm Parse::Yapp::Driver
|
||||
- name: Build release
|
||||
run: cargo build --release --target ${{ matrix.platform.target }}
|
||||
run: cargo build --release --features smb-vendored --target ${{ matrix.platform.target }}
|
||||
- name: Prepare artifact files
|
||||
run: |
|
||||
mkdir -p .artifact
|
||||
|
||||
7
.github/workflows/macos.yml
vendored
7
.github/workflows/macos.yml
vendored
@@ -22,6 +22,13 @@ jobs:
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
brew update
|
||||
brew install \
|
||||
pkg-config \
|
||||
samba
|
||||
brew link --force samba
|
||||
- name: Build
|
||||
run: cargo build
|
||||
- name: Run tests
|
||||
|
||||
6
.github/workflows/website.yml
vendored
6
.github/workflows/website.yml
vendored
@@ -33,11 +33,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v2
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: "./site/"
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
2
.github/workflows/windows.yml
vendored
2
.github/workflows/windows.yml
vendored
@@ -15,7 +15,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-2019
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,6 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
- [Changelog](#changelog)
|
||||
- [0.19.0](#0190)
|
||||
- [0.18.0](#0180)
|
||||
- [0.17.0](#0170)
|
||||
- [0.16.1](#0161)
|
||||
- [0.16.0](#0160)
|
||||
@@ -40,6 +42,30 @@
|
||||
|
||||
---
|
||||
|
||||
## 0.19.0
|
||||
|
||||
Released on 20/09/2025
|
||||
|
||||
- [Issue 356](https://github.com/veeso/termscp/issues/356): Fixed SSH auth issue not trying with the password if any RSA key was found.
|
||||
- [Issue 334](https://github.com/veeso/termscp/issues/334): SMB support for MacOS with vendored build of libsmbclient.
|
||||
- [Issue 337](https://github.com/veeso/termscp/issues/337): Migrated to libssh.org on Linux and MacOS for better ssh agent support.
|
||||
- [Issue 361](https://github.com/veeso/termscp/issues/361): Report a message while calculating total size of files to transfer.
|
||||
- [Issue 354](https://github.com/veeso/termscp/issues/354):
|
||||
- Removed error popup message if failed to check for updates.
|
||||
- Prevent long timeouts when checking for updates if the network is down or the DNS is not working.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
Released on 10/06/2025
|
||||
|
||||
- 🐚 An **Embedded shell for termscp**:
|
||||
- [Issue 340](https://github.com/veeso/termscp/issues/340): Replaced the `Exec` popup with a **fully functional terminal emulator** embedded thanks to [A-Kenji's tui-term](https://github.com/a-kenji/tui-term).
|
||||
- Command History
|
||||
- Support for `cd` and `exit` commands as well.
|
||||
- Exit just closes the terminal emulator.
|
||||
- [Issue 345](https://github.com/veeso/termscp/issues/345): Default keys are used from `~/.ssh` directory if no keys are resolved for the host.
|
||||
- **Updated dependencies** and updated the Rust edition to `2024`
|
||||
|
||||
## 0.17.0
|
||||
|
||||
Released on 24/03/2025
|
||||
|
||||
@@ -2,75 +2,131 @@
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at christian.visintin1997@gmail.com. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
reported to the community leaders responsible for enforcement at
|
||||
[INSERT CONTACT METHOD].
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at <https://www.contributor-covenant.org/version/1/4/code-of-conduct.html>
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
<https://www.contributor-covenant.org/faq>
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
|
||||
1793
Cargo.lock
generated
1793
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
@@ -10,8 +10,8 @@ license = "MIT"
|
||||
name = "termscp"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/veeso/termscp"
|
||||
version = "0.17.0"
|
||||
rust-version = "1.85.0"
|
||||
version = "0.19.0"
|
||||
rust-version = "1.87.0"
|
||||
|
||||
[package.metadata.rpm]
|
||||
package = "termscp"
|
||||
@@ -60,6 +60,7 @@ regex = "^1"
|
||||
remotefs = "^0.3"
|
||||
remotefs-aws-s3 = "0.4"
|
||||
remotefs-kube = "0.4"
|
||||
remotefs-smb = { version = "^0.3", optional = true }
|
||||
remotefs-webdav = "^0.2"
|
||||
rpassword = "^7"
|
||||
self_update = { version = "^0.42", default-features = false, features = [
|
||||
@@ -71,29 +72,33 @@ self_update = { version = "^0.42", default-features = false, features = [
|
||||
] }
|
||||
serde = { version = "^1", features = ["derive"] }
|
||||
simplelog = "^0.12"
|
||||
ssh2-config = "^0.4"
|
||||
ssh2-config = "^0.6"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1.44", features = ["rt"] }
|
||||
toml = "^0.8"
|
||||
tui-realm-stdlib = "2"
|
||||
tuirealm = "2"
|
||||
toml = "^0.9"
|
||||
tui-realm-stdlib = "3"
|
||||
tuirealm = "3"
|
||||
tui-term = "0.2"
|
||||
unicode-width = "^0.2"
|
||||
version-compare = "^0.2"
|
||||
whoami = "^1.5"
|
||||
whoami = "^1.6"
|
||||
wildmatch = "^2"
|
||||
|
||||
[target."cfg(not(target_os = \"macos\"))".dependencies]
|
||||
remotefs-smb = { version = "^0.3", optional = true }
|
||||
|
||||
[target."cfg(target_family = \"unix\")".dependencies]
|
||||
remotefs-ftp = { version = "^0.2", features = ["vendored", "native-tls"] }
|
||||
remotefs-ssh = { version = "^0.6", features = ["ssh2-vendored"] }
|
||||
remotefs-ftp = { version = "^0.3", features = [
|
||||
"native-tls-vendored",
|
||||
"native-tls",
|
||||
] }
|
||||
remotefs-ssh = { version = "^0.7", default-features = false, features = [
|
||||
"find",
|
||||
"libssh-vendored",
|
||||
] }
|
||||
uzers = "0.12"
|
||||
|
||||
[target."cfg(target_family = \"windows\")".dependencies]
|
||||
remotefs-ftp = { version = "^0.2", features = ["native-tls"] }
|
||||
remotefs-ssh = { version = "^0.6" }
|
||||
remotefs-ftp = { version = "^0.3", features = ["native-tls"] }
|
||||
remotefs-ssh = { version = "^0.7" }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "^1"
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">Developed by <a href="https://veeso.me/" target="_blank">@veeso</a></p>
|
||||
<p align="center">Current version: 0.17.0 24/03/2025</p>
|
||||
<p align="center">Current version: 0.19.0 10/06/2025</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"
|
||||
@@ -144,6 +144,7 @@ Termscp is a feature rich terminal file transfer and explorer, with support for
|
||||
- 📝 View and edit files with your favourite applications
|
||||
- 💁 SFTP/SCP authentication with SSH keys and username/password
|
||||
- 🐧 Compatible with Windows, Linux, FreeBSD, NetBSD and MacOS
|
||||
- 🐚 Embedded terminal for executing commands on the system.
|
||||
- 🎨 Make it yours!
|
||||
- Themes
|
||||
- Custom file explorer format
|
||||
|
||||
4
build.rs
4
build.rs
@@ -10,8 +10,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
posix: { target_family = "unix" },
|
||||
win: { target_family = "windows" },
|
||||
// exclusive features
|
||||
smb: { all(feature = "smb", not( macos )) },
|
||||
smb_unix: { all(unix, feature = "smb", not(macos)) },
|
||||
smb: { feature = "smb" },
|
||||
smb_unix: { all(unix, feature = "smb") },
|
||||
smb_windows: { all(windows, feature = "smb") }
|
||||
}
|
||||
|
||||
|
||||
4
dist/build/macos.sh
vendored
4
dist/build/macos.sh
vendored
@@ -81,7 +81,7 @@ fi
|
||||
# Build release (x86_64)
|
||||
X86_TARGET=""
|
||||
X86_TARGET_DIR=""
|
||||
if [ "$ARCH" = "aarch64" ]; then
|
||||
if [ "$ARCH" = "x86_64" ]; then
|
||||
X86_TARGET="--target x86_64-apple-darwin"
|
||||
X86_TARGET_DIR="target/x86_64-apple-darwin/release/"
|
||||
fi
|
||||
@@ -92,7 +92,7 @@ RET_X86_64=$?
|
||||
|
||||
ARM64_TARGET=""
|
||||
ARM64_TARGET_DIR=""
|
||||
if [ "$ARCH" = "aarch64" ]; then
|
||||
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
|
||||
ARM64_TARGET="--target aarch64-apple-darwin"
|
||||
ARM64_TARGET_DIR="target/aarch64-apple-darwin/release/"
|
||||
fi
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">Entwickelt von <a href="https://veeso.me/" target="_blank">@veeso</a></p>
|
||||
<p align="center">Aktuelle Version: 0.17.0 24/03/2025</p>
|
||||
<p align="center">Aktuelle Version: 0.19.0 10/06/2025</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">Desarrollado por <a href="https://veeso.me/" target="_blank">@veeso</a></p>
|
||||
<p align="center">Versión actual: 0.17.0 24/03/2025</p>
|
||||
<p align="center">Versión actual: 0.19.0 10/06/2025</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">Développé par <a href="https://veeso.me/" target="_blank">@veeso</a></p>
|
||||
<p align="center">Version actuelle: 0.17.0 24/03/2025</p>
|
||||
<p align="center">Version actuelle: 0.19.0 10/06/2025</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">Sviluppato da <a href="https://veeso.me/" target="_blank">@veeso</a></p>
|
||||
<p align="center">Versione corrente: 0.17.0 24/03/2025</p>
|
||||
<p align="center">Versione corrente: 0.19.0 10/06/2025</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">Desenvolvido por <a href="https://veeso.me/" target="_blank">@veeso</a></p>
|
||||
<p align="center">Versão atual: 0.17.0 24/03/2025</p>
|
||||
<p align="center">Versão atual: 0.19.0 10/06/2025</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">由 <a href="https://veeso.me/" target="_blank">@veeso</a> 开发</p>
|
||||
<p align="center">当前版本: 0.17.0 24/03/2025</p>
|
||||
<p align="center">当前版本: 0.19.0 10/06/2025</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://opensource.org/licenses/MIT"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# -f, -y, --force, --yes
|
||||
# Skip the confirmation prompt during installation
|
||||
|
||||
TERMSCP_VERSION="0.17.0"
|
||||
TERMSCP_VERSION="0.19.0"
|
||||
GITHUB_URL="https://github.com/veeso/termscp/releases/download/v${TERMSCP_VERSION}"
|
||||
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_amd64.deb"
|
||||
DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_arm64.deb"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<span translate="getStarted.windows.moderation">Consider that Chocolatey moderation can take up to a few weeks
|
||||
since last release, so if the latest version is not available yet,
|
||||
you can install it downloading the ZIP file from</span>
|
||||
<a href="https://github.com/veeso/termscp/releases/latest/download/termscp.0.17.0.nupkg"
|
||||
<a href="https://github.com/veeso/termscp/releases/latest/download/termscp.0.19.0.nupkg"
|
||||
target="_blank">Github</a>
|
||||
<span translate="getStarted.windows.then">and then, from the ZIP directory, install it via</span>
|
||||
</p>
|
||||
@@ -74,7 +74,7 @@
|
||||
On Debian based distros, you can install termscp using the Deb
|
||||
package via:
|
||||
</p>
|
||||
<pre><span class="function">wget</span> -O termscp.deb <span class="string">https://github.com/veeso/termscp/releases/latest/download/termscp_0.17.0_amd64.deb</span>
|
||||
<pre><span class="function">wget</span> -O termscp.deb <span class="string">https://github.com/veeso/termscp/releases/latest/download/termscp_0.19.0_amd64.deb</span>
|
||||
sudo <span class="function">dpkg</span> -i <span class="string">termscp.deb</span></pre>
|
||||
</div>
|
||||
<h3>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</button>
|
||||
<div class="p-4 my-4 text-sm text-green-800 rounded-lg bg-green-50">
|
||||
<p class="text-lg">
|
||||
<span translate="intro.versionAlert">termscp 0.17.0 is NOW out! Download it from</span>
|
||||
<span translate="intro.versionAlert">termscp 0.19.0 is NOW out! Download it from</span>
|
||||
<a href="/get-started.html" translate="intro.here">here!</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"intro": {
|
||||
"caption": "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV",
|
||||
"getStarted": "Get started →",
|
||||
"versionAlert": "termscp 0.17.0 is NOW out! Download it from",
|
||||
"versionAlert": "termscp 0.19.0 is NOW out! Download it from",
|
||||
"here": "here",
|
||||
"features": {
|
||||
"handy": {
|
||||
@@ -112,4 +112,4 @@
|
||||
"then": "Once started, you will be prompted whether to install or not the update. Confirm the installation and ta-dah, the new version of termscp should now be available on your machine"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"intro": {
|
||||
"caption": "Un explorador y transferencia de archivos de terminal rico en funciones, con apoyo para SCP/SFTP/FTP/Kube/S3/WebDAV",
|
||||
"getStarted": "Para iniciar →",
|
||||
"versionAlert": "termscp 0.17.0 ya está disponible! Descárgalo desde",
|
||||
"versionAlert": "termscp 0.19.0 ya está disponible! Descárgalo desde",
|
||||
"here": "aquì",
|
||||
"features": {
|
||||
"handy": {
|
||||
@@ -112,4 +112,4 @@
|
||||
"then": "Una vez iniciado, se le preguntará si desea instalar o no la actualización. Confirme la instalación y ta-dah, la nueva versión de termscp ahora debería estar disponible en su máquina"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"intro": {
|
||||
"caption": "Un file transfer et navigateur de terminal riche en fonctionnalités avec support pour SCP/SFTP/FTP/Kube/S3/WebDAV",
|
||||
"getStarted": "Pour commencer →",
|
||||
"versionAlert": "termscp 0.17.0 est maintenant sorti! Télécharge-le depuis",
|
||||
"versionAlert": "termscp 0.19.0 est maintenant sorti! Télécharge-le depuis",
|
||||
"here": "ici",
|
||||
"features": {
|
||||
"handy": {
|
||||
@@ -112,4 +112,4 @@
|
||||
"then": "Une fois démarré, vous serez invité à installer ou non la mise à jour. Confirmez l'installation et ta-dah, la nouvelle version de termscp devrait maintenant être disponible sur votre machine"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"intro": {
|
||||
"caption": "Un file transfer ed explorer ricco di funzionalità con supporto per SFTP/SCP/FTP/S3",
|
||||
"getStarted": "Installa termscp →",
|
||||
"versionAlert": "termscp 0.17.0 è ORA disponbile! Scaricalo da",
|
||||
"versionAlert": "termscp 0.19.0 è ORA disponbile! Scaricalo da",
|
||||
"here": "qui",
|
||||
"features": {
|
||||
"handy": {
|
||||
@@ -112,4 +112,4 @@
|
||||
"then": "Una volta lanciato, se c'è un aggiornamento disponibile ti chiederà se procedere. Conferma e a questo punto dovrebbe installarlo. Se tutto è andato a buon fine, riavviando termscp dovrebbe essere l'ultima versione."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"intro": {
|
||||
"caption": "功能丰富的终端 UI 文件传输和浏览器,支持 SCP/SFTP/FTP/Kube/S3/WebDAV",
|
||||
"getStarted": "开始 →",
|
||||
"versionAlert": "termscp 0.17.0 现已发布! 从下载",
|
||||
"versionAlert": "termscp 0.19.0 现已发布! 从下载",
|
||||
"here": "这里",
|
||||
"features": {
|
||||
"handy": {
|
||||
@@ -112,4 +112,4 @@
|
||||
"then": "启动后,系统将提示您是否安装更新。 确认安装和 ta-dah,新版本的termscp 现在应该可以在你的机器上使用了"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,10 +504,7 @@ impl Formatter {
|
||||
};
|
||||
// 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,
|
||||
},
|
||||
Some(len) => len.as_str().parse::<usize>().ok(),
|
||||
None => None,
|
||||
};
|
||||
// Match format extra: group 2 + 1
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct FileExplorer {
|
||||
pub(crate) opts: ExplorerOpts,
|
||||
/// Formatter for file entries
|
||||
pub(crate) fmt: Formatter,
|
||||
/// Is terminal open for this explorer?
|
||||
terminal: bool,
|
||||
/// Files in directory
|
||||
files: Vec<File>,
|
||||
/// files enqueued for transfer. Map between source and destination
|
||||
@@ -73,6 +75,7 @@ impl Default for FileExplorer {
|
||||
opts: ExplorerOpts::empty(),
|
||||
fmt: Formatter::default(),
|
||||
files: Vec::new(),
|
||||
terminal: false,
|
||||
transfer_queue: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -179,6 +182,15 @@ impl FileExplorer {
|
||||
self.transfer_queue.clear();
|
||||
}
|
||||
|
||||
/// Toggle terminal state
|
||||
pub fn toggle_terminal(&mut self, terminal: bool) {
|
||||
self.terminal = terminal;
|
||||
}
|
||||
|
||||
pub fn terminal_open(&self) -> bool {
|
||||
self.terminal
|
||||
}
|
||||
|
||||
// Formatting
|
||||
|
||||
/// Format a file entry
|
||||
|
||||
@@ -31,6 +31,16 @@ impl HostBridgeParams {
|
||||
HostBridgeParams::Remote(_, params) => params,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the host name for the bridge params
|
||||
pub fn username(&self) -> Option<String> {
|
||||
match self {
|
||||
HostBridgeParams::Localhost(_) => Some(whoami::username()),
|
||||
HostBridgeParams::Remote(_, params) => {
|
||||
params.generic_params().and_then(|p| p.username.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds connection parameters for file transfers
|
||||
@@ -42,6 +52,15 @@ pub struct FileTransferParams {
|
||||
pub local_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl FileTransferParams {
|
||||
/// Returns the remote path if set, otherwise returns the local path
|
||||
pub fn username(&self) -> Option<String> {
|
||||
self.params
|
||||
.generic_params()
|
||||
.and_then(|p| p.username.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Container for protocol params
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProtocolParams {
|
||||
|
||||
@@ -13,6 +13,10 @@ use remotefs_kube::KubeMultiPodFs as KubeFs;
|
||||
use remotefs_smb::SmbOptions;
|
||||
#[cfg(smb)]
|
||||
use remotefs_smb::{SmbCredentials, SmbFs};
|
||||
#[cfg(windows)]
|
||||
use remotefs_ssh::LibSsh2Session as SshSession;
|
||||
#[cfg(unix)]
|
||||
use remotefs_ssh::LibSshSession as SshSession;
|
||||
use remotefs_ssh::{ScpFs, SftpFs, SshAgentIdentity, SshConfigParseRule, SshOpts};
|
||||
use remotefs_webdav::WebDAVFs;
|
||||
|
||||
@@ -138,12 +142,18 @@ impl RemoteFsBuilder {
|
||||
}
|
||||
|
||||
/// Build scp client
|
||||
fn scp_client(params: GenericProtocolParams, config_client: &ConfigClient) -> ScpFs {
|
||||
fn scp_client(
|
||||
params: GenericProtocolParams,
|
||||
config_client: &ConfigClient,
|
||||
) -> ScpFs<SshSession> {
|
||||
Self::build_ssh_opts(params, config_client).into()
|
||||
}
|
||||
|
||||
/// Build sftp client
|
||||
fn sftp_client(params: GenericProtocolParams, config_client: &ConfigClient) -> SftpFs {
|
||||
fn sftp_client(
|
||||
params: GenericProtocolParams,
|
||||
config_client: &ConfigClient,
|
||||
) -> SftpFs<SshSession> {
|
||||
Self::build_ssh_opts(params, config_client).into()
|
||||
}
|
||||
|
||||
@@ -233,8 +243,12 @@ impl RemoteFsBuilder {
|
||||
debug!("no username was provided, using current username");
|
||||
opts = opts.username(whoami::username());
|
||||
}
|
||||
// For SSH protocols, only set password if explicitly provided and non-empty.
|
||||
// This allows the SSH library to prioritize key-based and agent authentication.
|
||||
if let Some(password) = params.password {
|
||||
opts = opts.password(password);
|
||||
if !password.is_empty() {
|
||||
opts = opts.password(password);
|
||||
}
|
||||
}
|
||||
if let Some(config_path) = config_client.get_ssh_config() {
|
||||
opts = opts.config_file(
|
||||
|
||||
@@ -79,10 +79,7 @@ fn get_config_client() -> Option<ConfigClient> {
|
||||
Err(_) => None,
|
||||
Ok(dir) => {
|
||||
let (cfg_path, ssh_key_dir) = environment::get_config_paths(dir.as_path());
|
||||
match ConfigClient::new(cfg_path.as_path(), ssh_key_dir.as_path()) {
|
||||
Err(_) => None,
|
||||
Ok(c) => Some(c),
|
||||
}
|
||||
ConfigClient::new(cfg_path.as_path(), ssh_key_dir.as_path()).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//!
|
||||
//! Automatic update module. This module is used to upgrade the current version of termscp to the latest available on Github
|
||||
|
||||
use std::net::ToSocketAddrs as _;
|
||||
|
||||
use self_update::backends::github::Update as GithubUpdater;
|
||||
pub use self_update::errors::Error as UpdateError;
|
||||
use self_update::update::Release as UpdRelease;
|
||||
@@ -67,6 +69,9 @@ impl Update {
|
||||
/// otherwise if no version is available, return None
|
||||
/// In case of error returns Error with the error description
|
||||
pub fn is_new_version_available() -> Result<Option<Release>, UpdateError> {
|
||||
// check if api.github.com is reachable before doing anything
|
||||
Self::check_github_api_reachable()?;
|
||||
|
||||
info!("Checking whether a new version is available...");
|
||||
GithubUpdater::configure()
|
||||
// Set default options
|
||||
@@ -83,6 +88,27 @@ impl Update {
|
||||
.map(Self::check_version)
|
||||
}
|
||||
|
||||
/// Check if api.github.com is reachable
|
||||
/// This is useful to avoid long timeouts when the network is down
|
||||
/// or the DNS is not working
|
||||
fn check_github_api_reachable() -> Result<(), UpdateError> {
|
||||
let Some(socket_addr) = ("api.github.com", 443)
|
||||
.to_socket_addrs()
|
||||
.ok()
|
||||
.and_then(|mut i| i.next())
|
||||
else {
|
||||
error!("Could not resolve api.github.com");
|
||||
return Err(UpdateError::Network(
|
||||
"Could not resolve api.github.com".into(),
|
||||
));
|
||||
};
|
||||
|
||||
// just try to open a connection to api.github.com with a timeout of 5 seconds with tcp
|
||||
std::net::TcpStream::connect_timeout(&socket_addr, std::time::Duration::from_secs(5))
|
||||
.map(|_| ())
|
||||
.map_err(|e| UpdateError::Network(format!("Could not reach api.github.com: {e}")))
|
||||
}
|
||||
|
||||
/// In case received version is newer than current one, version as Some is returned; otherwise None
|
||||
fn check_version(r: Release) -> Option<Release> {
|
||||
debug!("got version from GitHub: {}", r.version);
|
||||
@@ -212,4 +238,9 @@ mod test {
|
||||
assert!(!Update::is_new_version_higher("0.9.9", "0.10.1"));
|
||||
assert!(!Update::is_new_version_higher("0.10.9", "0.11.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_check_whether_github_api_is_reachable() {
|
||||
assert!(Update::check_github_api_reachable().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +153,7 @@ impl ConfigClient {
|
||||
// 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,
|
||||
},
|
||||
Some(val) => GroupDirs::from_str(val.as_str()).ok(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,15 +44,29 @@ impl SshKeyStorage {
|
||||
/// Resolve host via ssh2 configuration
|
||||
fn resolve_host_in_ssh2_configuration(&self, host: &str) -> Option<PathBuf> {
|
||||
self.ssh_config.as_ref().and_then(|x| {
|
||||
let key = x
|
||||
.query(host)
|
||||
x.query(host)
|
||||
.identity_file
|
||||
.as_ref()
|
||||
.and_then(|x| x.first().cloned());
|
||||
|
||||
key
|
||||
.and_then(|x| x.first().cloned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get default SSH identity files that SSH would normally try
|
||||
/// This mirrors the behavior of OpenSSH client
|
||||
fn get_default_identity_files(&self) -> Vec<PathBuf> {
|
||||
let Some(home_dir) = dirs::home_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let ssh_dir = home_dir.join(".ssh");
|
||||
|
||||
// Standard SSH identity files in order of preference (matches OpenSSH)
|
||||
["id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"]
|
||||
.iter()
|
||||
.map(|key_name| ssh_dir.join(key_name))
|
||||
.filter(|key_path| key_path.exists())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SshKeyStorageTrait for SshKeyStorage {
|
||||
@@ -66,9 +80,13 @@ impl SshKeyStorageTrait for SshKeyStorage {
|
||||
username, host
|
||||
);
|
||||
// otherwise search in configuration
|
||||
let key = self.resolve_host_in_ssh2_configuration(host)?;
|
||||
debug!("Found key in SSH config for {host}: {}", key.display());
|
||||
Some(key)
|
||||
if let Some(key) = self.resolve_host_in_ssh2_configuration(host) {
|
||||
debug!("Found key in SSH config for {host}: {}", key.display());
|
||||
return Some(key);
|
||||
}
|
||||
|
||||
// As a final fallback, try default SSH identity files (like regular ssh does)
|
||||
self.get_default_identity_files().into_iter().next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +155,14 @@ mod tests {
|
||||
*storage.resolve("192.168.1.31", "pi").unwrap(),
|
||||
exp_key_path
|
||||
);
|
||||
// Verify unexisting key
|
||||
assert!(storage.resolve("deskichup", "veeso").is_none());
|
||||
// Verify key is a default key or none
|
||||
let default_keys: Vec<PathBuf> = storage.get_default_identity_files().into_iter().collect();
|
||||
|
||||
if let Some(key) = storage.resolve("deskichup", "veeso") {
|
||||
assert!(default_keys.contains(&key));
|
||||
} else {
|
||||
assert!(default_keys.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -197,7 +197,7 @@ impl DeleteBookmarkPopup {
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.value(1)
|
||||
.rewind(true)
|
||||
.foreground(color)
|
||||
@@ -265,7 +265,7 @@ impl DeleteRecentPopup {
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.value(1)
|
||||
.rewind(true)
|
||||
.foreground(color)
|
||||
@@ -337,7 +337,7 @@ impl BookmarkSavePassword {
|
||||
.sides(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.value(0)
|
||||
.rewind(true)
|
||||
.foreground(color)
|
||||
|
||||
@@ -36,9 +36,9 @@ impl RemoteProtocolRadio {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(if cfg!(smb) {
|
||||
&["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV", "SMB"]
|
||||
vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV", "SMB"].into_iter()
|
||||
} else {
|
||||
&["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV"]
|
||||
vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV"].into_iter()
|
||||
})
|
||||
.foreground(color)
|
||||
.rewind(true)
|
||||
@@ -126,7 +126,7 @@ impl HostBridgeProtocolRadio {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(if cfg!(smb) {
|
||||
&[
|
||||
vec![
|
||||
"Localhost",
|
||||
"SFTP",
|
||||
"SCP",
|
||||
@@ -137,8 +137,9 @@ impl HostBridgeProtocolRadio {
|
||||
"WebDAV",
|
||||
"SMB",
|
||||
]
|
||||
.into_iter()
|
||||
} else {
|
||||
&[
|
||||
vec![
|
||||
"Localhost",
|
||||
"SFTP",
|
||||
"SCP",
|
||||
@@ -148,6 +149,7 @@ impl HostBridgeProtocolRadio {
|
||||
"Kube",
|
||||
"WebDAV",
|
||||
]
|
||||
.into_iter()
|
||||
})
|
||||
.foreground(color)
|
||||
.rewind(true)
|
||||
@@ -649,7 +651,7 @@ impl RadioS3NewPathStyle {
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.foreground(color)
|
||||
.rewind(true)
|
||||
.title("New path style", Alignment::Left)
|
||||
|
||||
@@ -28,7 +28,7 @@ impl ErrorPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[TextSpan::from(text.as_ref())])
|
||||
.text([TextSpan::from(text.as_ref())])
|
||||
.wrap(true),
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ impl InfoPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[TextSpan::from(text.as_ref())])
|
||||
.text([TextSpan::from(text.as_ref())])
|
||||
.wrap(true),
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ impl WaitPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[TextSpan::from(text.as_ref())])
|
||||
.text([TextSpan::from(text.as_ref())])
|
||||
.wrap(true),
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ impl WindowSizeError {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[TextSpan::from(
|
||||
.text([TextSpan::from(
|
||||
"termscp requires at least 24 lines of height to run",
|
||||
)])
|
||||
.wrap(true),
|
||||
@@ -163,7 +163,7 @@ impl QuitPopup {
|
||||
.foreground(color)
|
||||
.title("Quit termscp?", Alignment::Center)
|
||||
.rewind(true)
|
||||
.choices(&["Yes", "No"]),
|
||||
.choices(["Yes", "No"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@ impl InstallUpdatePopup {
|
||||
.foreground(color)
|
||||
.title("Install update?", Alignment::Center)
|
||||
.rewind(true)
|
||||
.choices(&["Yes", "No"]),
|
||||
.choices(["Yes", "No"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,13 +296,7 @@ impl ReleaseNotes {
|
||||
)
|
||||
.foreground(color)
|
||||
.title("Release notes", Alignment::Center)
|
||||
.text_rows(
|
||||
notes
|
||||
.lines()
|
||||
.map(TextSpan::from)
|
||||
.collect::<Vec<TextSpan>>()
|
||||
.as_slice(),
|
||||
),
|
||||
.text_rows(notes.lines().map(TextSpan::from)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ pub struct NewVersionDisclaimer {
|
||||
impl NewVersionDisclaimer {
|
||||
pub fn new(new_version: &str, color: Color) -> Self {
|
||||
Self {
|
||||
component: Span::default().foreground(color).spans(&[
|
||||
component: Span::default().foreground(color).spans([
|
||||
TextSpan::from("termscp "),
|
||||
TextSpan::new(new_version).underlined().bold(),
|
||||
TextSpan::from(
|
||||
@@ -91,7 +91,7 @@ pub struct HelpFooter {
|
||||
impl HelpFooter {
|
||||
pub fn new(key_color: Color) -> Self {
|
||||
Self {
|
||||
component: Span::default().spans(&[
|
||||
component: Span::default().spans([
|
||||
TextSpan::from("<F1|CTRL+H>").bold().fg(key_color),
|
||||
TextSpan::from(" Help "),
|
||||
TextSpan::from("<CTRL+C>").bold().fg(key_color),
|
||||
|
||||
@@ -223,10 +223,7 @@ impl AuthActivity {
|
||||
}
|
||||
Err(err) => {
|
||||
// Report error
|
||||
error!("Failed to get latest version: {}", err);
|
||||
self.mount_error(
|
||||
format!("Could not check for new updates: {err}").as_str(),
|
||||
);
|
||||
error!("Failed to get latest version: {err}",);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -2,41 +2,127 @@
|
||||
//!
|
||||
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
// locals
|
||||
use super::{FileTransferActivity, LogLevel};
|
||||
|
||||
/// Terminal command
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Command {
|
||||
Cd(String),
|
||||
Exec(String),
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl FromStr for Command {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut parts = s.split_whitespace();
|
||||
match parts.next() {
|
||||
Some("cd") => {
|
||||
if let Some(path) = parts.next() {
|
||||
Ok(Command::Cd(path.to_string()))
|
||||
} else {
|
||||
Err("cd command requires a path".to_string())
|
||||
}
|
||||
}
|
||||
Some("exit") | Some("logout") => Ok(Command::Exit),
|
||||
Some(cmd) => Ok(Command::Exec(cmd.to_string())),
|
||||
None => Err("".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileTransferActivity {
|
||||
pub(crate) fn action_local_exec(&mut self, input: String) {
|
||||
match self.host_bridge.exec(input.as_str()) {
|
||||
Ok(output) => {
|
||||
// Reload files
|
||||
self.log(LogLevel::Info, format!("\"{input}\": {output}"));
|
||||
}
|
||||
self.action_exec(false, input);
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_exec(&mut self, input: String) {
|
||||
self.action_exec(true, input);
|
||||
}
|
||||
|
||||
fn action_exec(&mut self, remote: bool, cmd: String) {
|
||||
if cmd.is_empty() {
|
||||
self.print_terminal("".to_string());
|
||||
}
|
||||
|
||||
let cmd = match Command::from_str(&cmd) {
|
||||
Ok(cmd) => cmd,
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
LogLevel::Error,
|
||||
format!("Could not execute command \"{input}\": {err}"),
|
||||
);
|
||||
self.log(LogLevel::Error, format!("Invalid command: {err}"));
|
||||
self.print_terminal(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match cmd {
|
||||
Command::Cd(path) => {
|
||||
self.action_exec_cd(remote, path);
|
||||
}
|
||||
Command::Exec(executable) => {
|
||||
self.action_exec_executable(remote, executable);
|
||||
}
|
||||
Command::Exit => {
|
||||
self.action_exec_exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn action_remote_exec(&mut self, input: String) {
|
||||
match self.client.as_mut().exec(input.as_str()) {
|
||||
Ok((rc, output)) => {
|
||||
// Reload files
|
||||
self.log(
|
||||
LogLevel::Info,
|
||||
format!("\"{input}\" (exitcode: {rc}): {output}"),
|
||||
);
|
||||
fn action_exec_exit(&mut self) {
|
||||
self.browser.toggle_terminal(false);
|
||||
self.umount_exec();
|
||||
}
|
||||
|
||||
fn action_exec_cd(&mut self, remote: bool, input: String) {
|
||||
let new_dir = if remote {
|
||||
let dir_path: PathBuf =
|
||||
self.remote_to_abs_path(PathBuf::from(input.as_str()).as_path());
|
||||
self.remote_changedir(dir_path.as_path(), true);
|
||||
|
||||
dir_path
|
||||
} else {
|
||||
let dir_path: PathBuf =
|
||||
self.host_bridge_to_abs_path(PathBuf::from(input.as_str()).as_path());
|
||||
self.host_bridge_changedir(dir_path.as_path(), true);
|
||||
|
||||
dir_path
|
||||
};
|
||||
|
||||
self.update_browser_file_list();
|
||||
|
||||
// update prompt and print the new directory
|
||||
self.update_terminal_prompt();
|
||||
self.print_terminal(new_dir.display().to_string());
|
||||
}
|
||||
|
||||
/// Execute a [`Command::Exec`] command
|
||||
fn action_exec_executable(&mut self, remote: bool, cmd: String) {
|
||||
let res = if remote {
|
||||
self.client
|
||||
.as_mut()
|
||||
.exec(cmd.as_str())
|
||||
.map(|(_, output)| output)
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
self.host_bridge
|
||||
.exec(cmd.as_str())
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(output) => {
|
||||
self.print_terminal(output);
|
||||
}
|
||||
Err(err) => {
|
||||
// Report err
|
||||
self.log_and_alert(
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!("Could not execute command \"{input}\": {err}"),
|
||||
format!("Could not execute command \"{cmd}\": {err}"),
|
||||
);
|
||||
self.print_terminal(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ impl FileTransferActivity {
|
||||
Ok(_) => self.log(LogLevel::Info, format!("Opened file `{}`", p.display())),
|
||||
Err(err) => self.log(
|
||||
LogLevel::Error,
|
||||
format!("Failed to open filoe `{}`: {}", p.display(), err),
|
||||
format!("Failed to open file `{}`: {}", p.display(), err),
|
||||
),
|
||||
}
|
||||
// NOTE: clear screen in order to prevent crap on stderr
|
||||
|
||||
@@ -53,12 +53,20 @@ impl MockComponent for Log {
|
||||
.unwrap()
|
||||
.unwrap_table()
|
||||
.iter()
|
||||
.map(|row| ListItem::new(tui_realm_stdlib::utils::wrap_spans(row, width, &self.props)))
|
||||
.map(|row| {
|
||||
let row_refs = row.iter().collect::<Vec<_>>();
|
||||
ListItem::new(tui_realm_stdlib::utils::wrap_spans(
|
||||
row_refs.as_slice(),
|
||||
width,
|
||||
&self.props,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let title = ("Log".to_string(), Alignment::Left);
|
||||
let w = TuiList::new(list_items)
|
||||
.block(tui_realm_stdlib::utils::get_block(
|
||||
borders,
|
||||
Some(("Log".to_string(), Alignment::Left)),
|
||||
Some(&title),
|
||||
focus,
|
||||
None,
|
||||
))
|
||||
|
||||
@@ -16,7 +16,7 @@ pub struct FooterBar {
|
||||
impl FooterBar {
|
||||
pub fn new(key_color: Color) -> Self {
|
||||
Self {
|
||||
component: Span::default().spans(&[
|
||||
component: Span::default().spans([
|
||||
TextSpan::from("<F1|H>").bold().fg(key_color),
|
||||
TextSpan::from(" Help "),
|
||||
TextSpan::from("<TAB>").bold().fg(key_color),
|
||||
|
||||
@@ -13,12 +13,13 @@ mod log;
|
||||
mod misc;
|
||||
mod popups;
|
||||
mod selected_files;
|
||||
mod terminal;
|
||||
mod transfer;
|
||||
|
||||
pub use misc::FooterBar;
|
||||
pub use popups::{
|
||||
ATTR_FILES, ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, ExecPopup,
|
||||
FatalPopup, FileInfoPopup, FilterPopup, GotoPopup, KeybindingsPopup, MkdirPopup, NewfilePopup,
|
||||
ATTR_FILES, ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, FatalPopup,
|
||||
FileInfoPopup, FilterPopup, GotoPopup, KeybindingsPopup, MkdirPopup, NewfilePopup,
|
||||
OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup,
|
||||
ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote,
|
||||
SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WalkdirWaitPopup, WatchedPathsList,
|
||||
@@ -28,6 +29,7 @@ pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote};
|
||||
|
||||
pub use self::log::Log;
|
||||
pub use self::selected_files::SelectedFilesList;
|
||||
pub use self::terminal::Terminal;
|
||||
|
||||
#[derive(Default, MockComponent)]
|
||||
pub struct GlobalListener {
|
||||
|
||||
@@ -214,7 +214,7 @@ impl DeletePopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.value(1)
|
||||
.title("Delete file(s)?", Alignment::Center),
|
||||
}
|
||||
@@ -279,7 +279,7 @@ impl DisconnectPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.title("Are you sure you want to disconnect?", Alignment::Center),
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ impl ErrorPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[TextSpan::from(text.as_ref())])
|
||||
.text([TextSpan::from(text.as_ref())])
|
||||
.wrap(true),
|
||||
}
|
||||
}
|
||||
@@ -362,89 +362,6 @@ impl Component<Msg, NoUserEvent> for ErrorPopup {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(MockComponent)]
|
||||
pub struct ExecPopup {
|
||||
component: Input,
|
||||
}
|
||||
|
||||
impl ExecPopup {
|
||||
pub fn new(color: Color) -> Self {
|
||||
Self {
|
||||
component: Input::default()
|
||||
.borders(
|
||||
Borders::default()
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.input_type(InputType::Text)
|
||||
.placeholder("ps a", Style::default().fg(Color::Rgb(128, 128, 128)))
|
||||
.title("Execute command", Alignment::Center),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component<Msg, NoUserEvent> for ExecPopup {
|
||||
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
|
||||
match ev {
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Left, ..
|
||||
}) => {
|
||||
self.perform(Cmd::Move(Direction::Left));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Right, ..
|
||||
}) => {
|
||||
self.perform(Cmd::Move(Direction::Right));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Home, ..
|
||||
}) => {
|
||||
self.perform(Cmd::GoTo(Position::Begin));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
|
||||
self.perform(Cmd::GoTo(Position::End));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Delete, ..
|
||||
}) => {
|
||||
self.perform(Cmd::Cancel);
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Backspace,
|
||||
..
|
||||
}) => {
|
||||
self.perform(Cmd::Delete);
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Char(ch),
|
||||
..
|
||||
}) => {
|
||||
self.perform(Cmd::Type(ch));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Enter, ..
|
||||
}) => match self.state() {
|
||||
State::One(StateValue::String(i)) => {
|
||||
Some(Msg::Transfer(TransferMsg::ExecuteCmd(i)))
|
||||
}
|
||||
_ => Some(Msg::None),
|
||||
},
|
||||
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
|
||||
Some(Msg::Ui(UiMsg::CloseExecPopup))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(MockComponent)]
|
||||
pub struct FatalPopup {
|
||||
component: Paragraph,
|
||||
@@ -461,7 +378,7 @@ impl FatalPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[TextSpan::from(text.as_ref())])
|
||||
.text([TextSpan::from(text.as_ref())])
|
||||
.wrap(true),
|
||||
}
|
||||
}
|
||||
@@ -497,6 +414,10 @@ impl FileInfoPopup {
|
||||
texts
|
||||
.add_col(TextSpan::from("Path: "))
|
||||
.add_col(TextSpan::new(path.as_str()).fg(Color::Yellow));
|
||||
texts
|
||||
.add_row()
|
||||
.add_col(TextSpan::from("Name: "))
|
||||
.add_col(TextSpan::new(file.name()).fg(Color::Yellow));
|
||||
if let Some(filetype) = file.extension() {
|
||||
texts
|
||||
.add_row()
|
||||
@@ -1121,7 +1042,7 @@ impl QuitPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.title("Are you sure you want to quit termscp?", Alignment::Center),
|
||||
}
|
||||
}
|
||||
@@ -1275,7 +1196,7 @@ impl ReplacePopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.title(text, Alignment::Center),
|
||||
}
|
||||
}
|
||||
@@ -1502,7 +1423,7 @@ impl SortingPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.choices(&["Name", "Modify time", "Creation time", "Size"])
|
||||
.choices(["Name", "Modify time", "Creation time", "Size"])
|
||||
.title("Sort files by…", Alignment::Center)
|
||||
.value(match value {
|
||||
FileSorting::CreationTime => 2,
|
||||
@@ -1554,7 +1475,7 @@ impl StatusBarLocal {
|
||||
let file_sorting = file_sorting_label(browser.host_bridge().file_sorting);
|
||||
let hidden_files = hidden_files_label(browser.host_bridge().hidden_files_visible());
|
||||
Self {
|
||||
component: Span::default().spans(&[
|
||||
component: Span::default().spans([
|
||||
TextSpan::new("File sorting: ").fg(sorting_color),
|
||||
TextSpan::new(file_sorting).fg(sorting_color).reversed(),
|
||||
TextSpan::new(" Hidden files: ").fg(hidden_color),
|
||||
@@ -1589,7 +1510,7 @@ impl StatusBarRemote {
|
||||
false => "OFF",
|
||||
};
|
||||
Self {
|
||||
component: Span::default().spans(&[
|
||||
component: Span::default().spans([
|
||||
TextSpan::new("File sorting: ").fg(sorting_color),
|
||||
TextSpan::new(file_sorting).fg(sorting_color).reversed(),
|
||||
TextSpan::new(" Hidden files: ").fg(hidden_color),
|
||||
@@ -1728,7 +1649,7 @@ impl SyncBrowsingMkdirPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.title(
|
||||
format!(
|
||||
r#"Sync browsing: directory "{dir_name}" doesn't exist. Do you want to create it?"#
|
||||
@@ -1802,7 +1723,7 @@ impl WaitPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[TextSpan::from(text.as_ref())])
|
||||
.text([TextSpan::from(text.as_ref())])
|
||||
.wrap(true),
|
||||
}
|
||||
}
|
||||
@@ -1830,7 +1751,7 @@ impl WalkdirWaitPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.text(&[
|
||||
.text([
|
||||
TextSpan::from(text.as_ref()),
|
||||
TextSpan::from("Press 'CTRL+C' to abort"),
|
||||
])
|
||||
@@ -1961,7 +1882,7 @@ impl WatcherPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.title(text, Alignment::Center),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,21 +59,21 @@ impl ChmodPopup {
|
||||
},
|
||||
user: Checkbox::default()
|
||||
.foreground(color)
|
||||
.choices(&["Read", "Write", "Execute"])
|
||||
.choices(["Read", "Write", "Execute"])
|
||||
.title("User", Alignment::Left)
|
||||
.borders(Borders::default().sides(BorderSides::NONE))
|
||||
.values(&make_pex_values(pex.user()))
|
||||
.rewind(true),
|
||||
group: Checkbox::default()
|
||||
.foreground(color)
|
||||
.choices(&["Read", "Write", "Execute"])
|
||||
.choices(["Read", "Write", "Execute"])
|
||||
.title("Group", Alignment::Left)
|
||||
.borders(Borders::default().sides(BorderSides::NONE))
|
||||
.values(&make_pex_values(pex.group()))
|
||||
.rewind(true),
|
||||
others: Checkbox::default()
|
||||
.foreground(color)
|
||||
.choices(&["Read", "Write", "Execute"])
|
||||
.choices(["Read", "Write", "Execute"])
|
||||
.title("Others", Alignment::Left)
|
||||
.borders(Borders::default().sides(BorderSides::NONE))
|
||||
.values(&make_pex_values(pex.others()))
|
||||
@@ -208,9 +208,11 @@ impl MockComponent for ChmodPopup {
|
||||
.get_or(Attribute::Focus, AttrValue::Flag(false))
|
||||
.unwrap_flag();
|
||||
|
||||
let div_title = (self.title.clone(), Alignment::Center);
|
||||
|
||||
let div = tui_realm_stdlib::utils::get_block(
|
||||
Borders::default().color(self.color),
|
||||
Some((self.title.clone(), Alignment::Center)),
|
||||
Some(&div_title),
|
||||
focus,
|
||||
None,
|
||||
);
|
||||
|
||||
136
src/ui/activities/filetransfer/components/terminal.rs
Normal file
136
src/ui/activities/filetransfer/components/terminal.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
mod component;
|
||||
mod history;
|
||||
mod line;
|
||||
|
||||
use tuirealm::command::{Cmd, CmdResult, Direction, Position};
|
||||
use tuirealm::event::{Key, KeyEvent};
|
||||
use tuirealm::props::Color;
|
||||
use tuirealm::{AttrValue, Attribute, Component, Event, MockComponent, NoUserEvent};
|
||||
|
||||
use self::component::TerminalComponent;
|
||||
use self::line::Line;
|
||||
use super::Msg;
|
||||
use crate::ui::activities::filetransfer::{TransferMsg, UiMsg};
|
||||
|
||||
#[derive(MockComponent, Default)]
|
||||
pub struct Terminal {
|
||||
component: TerminalComponent,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
/// Construct a new [`Terminal`] component with the given prompt line.
|
||||
pub fn prompt(mut self, prompt: impl ToString) -> Self {
|
||||
self.component = self.component.prompt(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
/// Construct a new [`Terminal`] component with the given title.
|
||||
pub fn title(mut self, title: impl ToString) -> Self {
|
||||
self.component
|
||||
.attr(Attribute::Title, AttrValue::String(title.to_string()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn border_color(mut self, color: Color) -> Self {
|
||||
self.component
|
||||
.attr(Attribute::Borders, AttrValue::Color(color));
|
||||
self
|
||||
}
|
||||
|
||||
/// Construct a new [`Terminal`] component with the foreground color
|
||||
pub fn foreground(mut self, color: Color) -> Self {
|
||||
self.component
|
||||
.attr(Attribute::Foreground, AttrValue::Color(color));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Component<Msg, NoUserEvent> for Terminal {
|
||||
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
|
||||
match ev {
|
||||
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
|
||||
Some(Msg::Ui(UiMsg::CloseExecPopup))
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Enter, ..
|
||||
}) => match self.component.perform(Cmd::Submit) {
|
||||
CmdResult::Submit(state) => {
|
||||
let cmd = state.unwrap_one().unwrap_string();
|
||||
Some(Msg::Transfer(TransferMsg::ExecuteCmd(cmd)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Home, ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::GoTo(Position::Begin));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
|
||||
self.component.perform(Cmd::GoTo(Position::End));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Backspace,
|
||||
..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Cancel);
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Delete, ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Delete);
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
|
||||
self.component.perform(Cmd::Move(Direction::Up));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Down, ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Move(Direction::Down));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Left, ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Move(Direction::Left));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Right, ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Move(Direction::Right));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Insert, ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Toggle);
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::PageDown,
|
||||
..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Scroll(Direction::Down));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::PageUp, ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Scroll(Direction::Up));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Char(c), ..
|
||||
}) => {
|
||||
self.component.perform(Cmd::Type(c));
|
||||
Some(Msg::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
289
src/ui/activities/filetransfer/components/terminal/component.rs
Normal file
289
src/ui/activities/filetransfer/components/terminal/component.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use tui_term::vt100::Parser;
|
||||
use tui_term::widget::PseudoTerminal;
|
||||
use tuirealm::command::{Cmd, CmdResult, Direction, Position};
|
||||
use tuirealm::props::{BorderSides, BorderType, Style};
|
||||
use tuirealm::ratatui::layout::Rect;
|
||||
use tuirealm::ratatui::widgets::Block;
|
||||
use tuirealm::{AttrValue, Attribute, MockComponent, Props, State, StateValue};
|
||||
|
||||
use super::Line;
|
||||
use super::history::History;
|
||||
|
||||
const DEFAULT_HISTORY_SIZE: usize = 128;
|
||||
|
||||
pub struct TerminalComponent {
|
||||
pub parser: Parser,
|
||||
history: History,
|
||||
line: Line,
|
||||
props: Props,
|
||||
scroll: usize,
|
||||
size: (u16, u16),
|
||||
}
|
||||
|
||||
impl Default for TerminalComponent {
|
||||
fn default() -> Self {
|
||||
let props = Props::default();
|
||||
let parser = Parser::new(40, 220, 2048);
|
||||
|
||||
TerminalComponent {
|
||||
parser,
|
||||
history: History::new(DEFAULT_HISTORY_SIZE),
|
||||
line: Line::default(),
|
||||
props,
|
||||
scroll: 0,
|
||||
size: (40, 220),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalComponent {
|
||||
/// Set prompt line for the terminal
|
||||
pub fn prompt(mut self, prompt: impl ToString) -> Self {
|
||||
self.attr(Attribute::Content, AttrValue::String(prompt.to_string()));
|
||||
self.write_prompt();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn write_prompt(&mut self) {
|
||||
if let Some(value) = self.query(Attribute::Content) {
|
||||
let prompt = value.unwrap_string();
|
||||
self.parser.process(prompt.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Set current line to the previous command in the [`History`]
|
||||
fn history_prev(&mut self) {
|
||||
if let Some(cmd) = self.history.previous() {
|
||||
self.write_line(cmd.as_bytes());
|
||||
self.line.set(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set current line to the next command in the [`History`]
|
||||
fn history_next(&mut self) {
|
||||
if let Some(cmd) = self.history.next() {
|
||||
self.write_line(cmd.as_bytes());
|
||||
self.line.set(cmd);
|
||||
} else {
|
||||
// If there is no next command, clear the line
|
||||
self.line.set(String::new());
|
||||
self.write_line(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a line to the terminal, processing it through the parser
|
||||
fn write_line(&mut self, data: &[u8]) {
|
||||
self.parser.process(b"\r");
|
||||
// blank the line
|
||||
self.write_prompt();
|
||||
self.parser.process(&[b' '; 15]);
|
||||
self.parser.process(b"\r");
|
||||
self.write_prompt();
|
||||
self.parser.process(data);
|
||||
}
|
||||
}
|
||||
|
||||
impl MockComponent for TerminalComponent {
|
||||
fn attr(&mut self, attr: tuirealm::Attribute, value: AttrValue) {
|
||||
if attr == Attribute::Text {
|
||||
if let tuirealm::AttrValue::String(s) = value {
|
||||
self.parser.process(b"\r");
|
||||
self.parser.process(s.as_bytes());
|
||||
self.parser.process(b"\r");
|
||||
self.write_prompt();
|
||||
}
|
||||
} else {
|
||||
self.props.set(attr, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn perform(&mut self, cmd: Cmd) -> CmdResult {
|
||||
match cmd {
|
||||
Cmd::Type(s) => {
|
||||
if !s.is_ascii() || self.scroll > 0 {
|
||||
return CmdResult::None; // Ignore non-ASCII characters or if scrolled
|
||||
}
|
||||
self.parser.process(&[s as u8]);
|
||||
self.line.push(s);
|
||||
CmdResult::Changed(self.state())
|
||||
}
|
||||
Cmd::Move(Direction::Down) => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move down if not scrolled
|
||||
}
|
||||
|
||||
self.history_next();
|
||||
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::Move(Direction::Left) => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move up if not scrolled
|
||||
}
|
||||
|
||||
if self.line.left() {
|
||||
self.parser.process(&[27, 91, 68]);
|
||||
}
|
||||
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::Move(Direction::Right) => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move up if not scrolled
|
||||
}
|
||||
|
||||
if self.line.right() {
|
||||
self.parser.process(&[27, 91, 67]);
|
||||
}
|
||||
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::Move(Direction::Up) => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move up if not scrolled
|
||||
}
|
||||
|
||||
self.history_prev();
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::Cancel => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move to the beginning if scrolled
|
||||
}
|
||||
|
||||
if !self.line.is_empty() {
|
||||
self.line.backspace();
|
||||
self.parser.process(&[8]); // Backspace character
|
||||
// delete the last character from the line
|
||||
// write one empty character to the terminal
|
||||
self.parser.process(&[32]); // Space character
|
||||
self.parser.process(&[8]); // Backspace character
|
||||
}
|
||||
CmdResult::Changed(self.state())
|
||||
}
|
||||
Cmd::Delete => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move to the beginning if scrolled
|
||||
}
|
||||
|
||||
if !self.line.is_empty() {
|
||||
self.line.delete();
|
||||
self.parser.process(&[27, 91, 51, 126]); // Delete character
|
||||
// write one empty character to the terminal
|
||||
self.parser.process(&[32]); // Space character
|
||||
self.parser.process(&[8]); // Backspace character
|
||||
}
|
||||
CmdResult::Changed(self.state())
|
||||
}
|
||||
Cmd::Scroll(Direction::Down) => {
|
||||
self.scroll = self.scroll.saturating_sub(8);
|
||||
self.parser.set_scrollback(self.scroll);
|
||||
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::Scroll(Direction::Up) => {
|
||||
self.parser.set_scrollback(self.scroll.saturating_add(8));
|
||||
let scrollback = self.parser.screen().scrollback();
|
||||
self.scroll = scrollback;
|
||||
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::Toggle => {
|
||||
// insert
|
||||
self.parser.process(&[27, 91, 50, 126]); // Toggle insert mode
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::GoTo(Position::Begin) => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move to the beginning if scrolled
|
||||
}
|
||||
|
||||
for _ in 0..self.line.begin() {
|
||||
self.parser.process(&[27, 91, 68]); // Move cursor to the left
|
||||
}
|
||||
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::GoTo(Position::End) => {
|
||||
if self.scroll > 0 {
|
||||
return CmdResult::None; // Cannot move to the beginning if scrolled
|
||||
}
|
||||
|
||||
for _ in 0..self.line.end() {
|
||||
self.parser.process(&[27, 91, 67]); // Move cursor to the right
|
||||
}
|
||||
CmdResult::None
|
||||
}
|
||||
Cmd::Submit => {
|
||||
self.scroll = 0; // Reset scroll on submit
|
||||
self.parser.set_scrollback(self.scroll);
|
||||
|
||||
if cfg!(target_family = "unix") {
|
||||
self.parser.process(b"\n");
|
||||
} else {
|
||||
self.parser.process(b"\r\n\r");
|
||||
}
|
||||
|
||||
let line = self.line.take();
|
||||
if !line.is_empty() {
|
||||
self.history.push(&line);
|
||||
}
|
||||
|
||||
CmdResult::Submit(State::One(StateValue::String(line)))
|
||||
}
|
||||
_ => CmdResult::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn query(&self, attr: tuirealm::Attribute) -> Option<tuirealm::AttrValue> {
|
||||
self.props.get(attr)
|
||||
}
|
||||
|
||||
fn state(&self) -> State {
|
||||
State::One(StateValue::String(self.line.content().to_string()))
|
||||
}
|
||||
|
||||
fn view(&mut self, frame: &mut tuirealm::Frame, area: Rect) {
|
||||
let width = area.width.saturating_sub(2);
|
||||
let height = area.height.saturating_sub(2);
|
||||
|
||||
// update the terminal size if it has changed
|
||||
if self.size != (width, height) {
|
||||
self.size = (width, height);
|
||||
self.parser.set_size(height, width);
|
||||
}
|
||||
|
||||
let title = self
|
||||
.query(Attribute::Title)
|
||||
.map(|value| value.unwrap_string())
|
||||
.unwrap_or_else(|| "Terminal".to_string());
|
||||
|
||||
let fg = self
|
||||
.query(Attribute::Foreground)
|
||||
.map(|value| value.unwrap_color())
|
||||
.unwrap_or(tuirealm::ratatui::style::Color::Reset);
|
||||
|
||||
let bg = self
|
||||
.query(Attribute::Background)
|
||||
.map(|value| value.unwrap_color())
|
||||
.unwrap_or(tuirealm::ratatui::style::Color::Reset);
|
||||
|
||||
let border_color = self
|
||||
.query(Attribute::Borders)
|
||||
.map(|value| value.unwrap_color())
|
||||
.unwrap_or(tuirealm::ratatui::style::Color::Reset);
|
||||
|
||||
let terminal = PseudoTerminal::new(self.parser.screen())
|
||||
.block(
|
||||
Block::default()
|
||||
.title(title)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(border_color))
|
||||
.borders(BorderSides::ALL)
|
||||
.style(Style::default().fg(fg).bg(bg)),
|
||||
)
|
||||
.style(Style::default().fg(fg).bg(bg));
|
||||
|
||||
frame.render_widget(terminal, area);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Shell history management module.
|
||||
#[derive(Debug)]
|
||||
pub struct History {
|
||||
/// Entries in the history.
|
||||
entries: VecDeque<String>,
|
||||
/// Maximum size of the history.
|
||||
max_size: usize,
|
||||
/// Current index in the history for navigation.
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// Create a new [`History`] with a specified maximum size.
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
History {
|
||||
entries: VecDeque::with_capacity(max_size),
|
||||
max_size,
|
||||
index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new command into the history.
|
||||
pub fn push(&mut self, cmd: &str) {
|
||||
if self.entries.len() == self.max_size {
|
||||
self.entries.pop_front();
|
||||
}
|
||||
self.entries.push_back(cmd.to_string());
|
||||
self.index = self.entries.len(); // Reset index to the end after adding a new command
|
||||
}
|
||||
|
||||
/// Get the previous command in the history.
|
||||
///
|
||||
/// Set also the index to the last command if it exists.
|
||||
pub fn previous(&mut self) -> Option<String> {
|
||||
if self.index > 0 {
|
||||
self.index -= 1;
|
||||
self.entries.get(self.index).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next command in the history.
|
||||
///
|
||||
/// Set also the index to the next command if it exists.
|
||||
pub fn next(&mut self) -> Option<String> {
|
||||
if self.index < self.entries.len() {
|
||||
let cmd = self.entries.get(self.index).cloned();
|
||||
self.index += 1;
|
||||
cmd
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::History;
|
||||
|
||||
#[test]
|
||||
fn test_history() {
|
||||
let mut history = History::new(5);
|
||||
history.push("first");
|
||||
history.push("second");
|
||||
history.push("third");
|
||||
|
||||
assert_eq!(history.previous(), Some("third".to_string()));
|
||||
assert_eq!(history.previous(), Some("second".to_string()));
|
||||
assert_eq!(history.previous(), Some("first".to_string()));
|
||||
assert_eq!(history.previous(), None); // No more previous commands
|
||||
assert_eq!(history.next(), Some("first".to_string()));
|
||||
assert_eq!(history.next(), Some("second".to_string()));
|
||||
assert_eq!(history.next(), Some("third".to_string()));
|
||||
assert_eq!(history.next(), None); // No more next commands
|
||||
history.push("fourth");
|
||||
assert_eq!(history.previous(), Some("fourth".to_string()));
|
||||
}
|
||||
}
|
||||
220
src/ui/activities/filetransfer/components/terminal/line.rs
Normal file
220
src/ui/activities/filetransfer/components/terminal/line.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
/// A simple line for the shell, which keeps track of the current
|
||||
/// content and the cursor position.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Line {
|
||||
content: String,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// Set the content of the line and reset the cursor to the end.
|
||||
pub fn set(&mut self, content: String) {
|
||||
self.cursor = content.len();
|
||||
self.content = content;
|
||||
}
|
||||
|
||||
// Push a character to the line at the current cursor position.
|
||||
pub fn push(&mut self, c: char) {
|
||||
self.content.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
}
|
||||
|
||||
/// Take the current line content and reset the cursor.
|
||||
pub fn take(&mut self) -> String {
|
||||
self.cursor = 0;
|
||||
std::mem::take(&mut self.content)
|
||||
}
|
||||
|
||||
/// Get a reference to the current line content.
|
||||
pub fn content(&self) -> &str {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Move the cursor to the left, if possible.
|
||||
///
|
||||
/// Returns `true` if the cursor was moved, `false` if it was already at the beginning.
|
||||
pub fn left(&mut self) -> bool {
|
||||
if self.cursor > 0 {
|
||||
// get the previous character length
|
||||
let prev_char_len = self
|
||||
.content
|
||||
.chars()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
if i < self.cursor {
|
||||
Some(c.len_utf8())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.last()
|
||||
.unwrap();
|
||||
self.cursor -= prev_char_len;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor to the right, if possible.
|
||||
///
|
||||
/// Returns `true` if the cursor was moved, `false` if it was already at the end.
|
||||
pub fn right(&mut self) -> bool {
|
||||
if self.cursor < self.content.len() {
|
||||
// get the next character length
|
||||
let next_char_len = self.content[self.cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap()
|
||||
.len_utf8();
|
||||
self.cursor += next_char_len;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor to the beginning of the line.
|
||||
///
|
||||
/// Returns the previous cursor position.
|
||||
pub fn begin(&mut self) -> usize {
|
||||
std::mem::take(&mut self.cursor)
|
||||
}
|
||||
|
||||
/// Move the cursor to the end of the line.
|
||||
///
|
||||
/// Returns the difference between the previous cursor position and the new position.
|
||||
pub fn end(&mut self) -> usize {
|
||||
let diff = self.content.len() - self.cursor;
|
||||
self.cursor = self.content.len();
|
||||
|
||||
diff
|
||||
}
|
||||
|
||||
/// Remove the previous character from the line at the current cursor position.
|
||||
pub fn backspace(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
let prev_char_len = self
|
||||
.content
|
||||
.chars()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
if i < self.cursor {
|
||||
Some(c.len_utf8())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.last()
|
||||
.unwrap();
|
||||
self.content.remove(self.cursor - prev_char_len);
|
||||
self.cursor -= prev_char_len;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes the character at the current cursor position.
|
||||
pub fn delete(&mut self) {
|
||||
if self.cursor < self.content.len() {
|
||||
self.content.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the line is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.content.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_line() {
|
||||
let mut line = Line::default();
|
||||
assert!(line.is_empty());
|
||||
|
||||
line.push('H');
|
||||
line.push('e');
|
||||
line.push('l');
|
||||
line.push('l');
|
||||
line.push('o');
|
||||
assert_eq!(line.content(), "Hello");
|
||||
|
||||
line.left();
|
||||
line.left();
|
||||
line.push(' ');
|
||||
assert_eq!(line.content(), "Hel lo");
|
||||
|
||||
line.begin();
|
||||
line.push('W');
|
||||
assert_eq!(line.content(), "WHel lo");
|
||||
|
||||
line.end();
|
||||
line.push('!');
|
||||
assert_eq!(line.content(), "WHel lo!");
|
||||
|
||||
let taken = line.take();
|
||||
assert_eq!(taken, "WHel lo!");
|
||||
assert!(line.is_empty());
|
||||
|
||||
line.set("New Line".to_string());
|
||||
assert_eq!(line.content(), "New Line");
|
||||
|
||||
line.backspace();
|
||||
assert_eq!(line.content(), "New Lin");
|
||||
line.left();
|
||||
line.delete();
|
||||
assert_eq!(line.content(), "New Li");
|
||||
line.left();
|
||||
line.left();
|
||||
line.right();
|
||||
assert_eq!(line.content(), "New Li");
|
||||
line.end();
|
||||
assert_eq!(line.content(), "New Li");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_return_whether_the_cursor_was_moved() {
|
||||
let mut line = Line::default();
|
||||
line.set("Hello".to_string());
|
||||
|
||||
assert!(line.left());
|
||||
assert_eq!(line.content(), "Hello");
|
||||
assert_eq!(line.cursor, 4);
|
||||
|
||||
assert!(line.left());
|
||||
assert_eq!(line.content(), "Hello");
|
||||
assert_eq!(line.cursor, 3);
|
||||
|
||||
assert!(line.right());
|
||||
assert_eq!(line.content(), "Hello");
|
||||
assert_eq!(line.cursor, 4);
|
||||
assert!(line.right());
|
||||
assert_eq!(line.content(), "Hello");
|
||||
assert!(!line.right());
|
||||
assert_eq!(line.cursor, 5);
|
||||
assert!(!line.right());
|
||||
|
||||
line.end();
|
||||
assert!(!line.right());
|
||||
assert_eq!(line.content(), "Hello");
|
||||
assert_eq!(line.cursor, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_allow_utf8_cursors() {
|
||||
let mut line = Line::default();
|
||||
line.set("Hello, 世界".to_string());
|
||||
assert_eq!(line.content(), "Hello, 世界");
|
||||
assert_eq!(line.cursor, 13); // "Hello, " is 7 bytes, "世界" is 6 bytes
|
||||
|
||||
assert!(line.left());
|
||||
assert_eq!(line.content(), "Hello, 世界");
|
||||
assert_eq!(line.cursor, 10); // Move left to '世'
|
||||
assert!(line.left());
|
||||
assert_eq!(line.content(), "Hello, 世界");
|
||||
assert_eq!(line.cursor, 7); // Move left to ','
|
||||
}
|
||||
}
|
||||
@@ -158,7 +158,7 @@ impl MockComponent for FileList {
|
||||
.props
|
||||
.get_or(Attribute::Focus, AttrValue::Flag(false))
|
||||
.unwrap_flag();
|
||||
let div = tui_realm_stdlib::utils::get_block(borders, Some(title), focus, None);
|
||||
let div = tui_realm_stdlib::utils::get_block(borders, Some(&title), focus, None);
|
||||
// Make list entries
|
||||
let init_table_iter = if self.has_dot_dot() {
|
||||
vec![vec![TextSpan::from("..")]]
|
||||
|
||||
@@ -57,7 +57,7 @@ impl FileListWithSearch {
|
||||
|
||||
pub fn borders(mut self, b: Borders) -> Self {
|
||||
self.file_list
|
||||
.attr(Attribute::Borders, AttrValue::Borders(b.clone()));
|
||||
.attr(Attribute::Borders, AttrValue::Borders(b));
|
||||
self.search.attr(Attribute::Borders, AttrValue::Borders(b));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ impl ExplorerFuzzy {
|
||||
.foreground(fg)
|
||||
.highlighted_color(hg)
|
||||
.title(title, Alignment::Left)
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect()),
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ impl ExplorerFind {
|
||||
.foreground(fg)
|
||||
.highlighted_color(hg)
|
||||
.title(title, Alignment::Left)
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect()),
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,7 +373,7 @@ impl ExplorerLocal {
|
||||
.foreground(fg)
|
||||
.highlighted_color(hg)
|
||||
.title(title, Alignment::Left)
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect())
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect())
|
||||
.dot_dot(true),
|
||||
}
|
||||
}
|
||||
@@ -547,7 +547,7 @@ impl Component<Msg, NoUserEvent> for ExplorerLocal {
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Char('x'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
}) => Some(Msg::Ui(UiMsg::ShowExecPopup)),
|
||||
}) => Some(Msg::Ui(UiMsg::ShowTerminal)),
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Char('y'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
@@ -587,7 +587,7 @@ impl ExplorerRemote {
|
||||
.foreground(fg)
|
||||
.highlighted_color(hg)
|
||||
.title(title, Alignment::Left)
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect())
|
||||
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect())
|
||||
.dot_dot(true),
|
||||
}
|
||||
}
|
||||
@@ -761,7 +761,7 @@ impl Component<Msg, NoUserEvent> for ExplorerRemote {
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Char('x'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
}) => Some(Msg::Ui(UiMsg::ShowExecPopup)),
|
||||
}) => Some(Msg::Ui(UiMsg::ShowTerminal)),
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Char('y'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
|
||||
@@ -148,6 +148,25 @@ impl Browser {
|
||||
self.sync_browsing = !self.sync_browsing;
|
||||
}
|
||||
|
||||
/// Toggle terminal for the current tab
|
||||
pub fn toggle_terminal(&mut self, terminal: bool) {
|
||||
if self.tab == FileExplorerTab::HostBridge {
|
||||
self.host_bridge.toggle_terminal(terminal);
|
||||
} else if self.tab == FileExplorerTab::Remote {
|
||||
self.remote.toggle_terminal(terminal);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if terminal is open for the host bridge tab
|
||||
pub fn is_terminal_open_host_bridge(&self) -> bool {
|
||||
self.tab == FileExplorerTab::HostBridge && self.host_bridge.terminal_open()
|
||||
}
|
||||
|
||||
/// Check if terminal is open for the remote tab
|
||||
pub fn is_terminal_open_remote(&self) -> bool {
|
||||
self.tab == FileExplorerTab::Remote && self.remote.terminal_open()
|
||||
}
|
||||
|
||||
/// Build a file explorer with local host setup
|
||||
pub fn build_local_explorer(cli: &ConfigClient) -> FileExplorer {
|
||||
let mut builder = Self::build_explorer(cli);
|
||||
|
||||
@@ -304,6 +304,70 @@ impl FileTransferActivity {
|
||||
self.reload_remote_filelist();
|
||||
}
|
||||
|
||||
pub(super) fn get_tab_hostname(&self) -> String {
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::HostBridge | FileExplorerTab::FindHostBridge => {
|
||||
self.get_hostbridge_hostname()
|
||||
}
|
||||
FileExplorerTab::Remote | FileExplorerTab::FindRemote => self.get_remote_hostname(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn terminal_prompt(&self) -> String {
|
||||
const TERM_CYAN: &str = "\x1b[36m";
|
||||
const TERM_GREEN: &str = "\x1b[32m";
|
||||
const TERM_YELLOW: &str = "\x1b[33m";
|
||||
const TERM_RESET: &str = "\x1b[0m";
|
||||
|
||||
let panel = self.browser.tab();
|
||||
match panel {
|
||||
FileExplorerTab::HostBridge | FileExplorerTab::FindHostBridge => {
|
||||
let username = self
|
||||
.context()
|
||||
.host_bridge_params()
|
||||
.and_then(|params| {
|
||||
params
|
||||
.username()
|
||||
.map(|u| format!("{TERM_CYAN}{u}{TERM_RESET}@"))
|
||||
})
|
||||
.unwrap_or("".to_string());
|
||||
let hostname = self.get_hostbridge_hostname();
|
||||
format!(
|
||||
"{username}{TERM_GREEN}{hostname}:{TERM_YELLOW}{}{TERM_RESET}$ ",
|
||||
fmt_path_elide_ex(
|
||||
self.host_bridge().wrkdir.as_path(),
|
||||
0,
|
||||
hostname.len() + 3 // 3 because of '/…/'
|
||||
)
|
||||
)
|
||||
}
|
||||
FileExplorerTab::Remote | FileExplorerTab::FindRemote => {
|
||||
let username = self
|
||||
.context()
|
||||
.remote_params()
|
||||
.and_then(|params| {
|
||||
params
|
||||
.username()
|
||||
.map(|u| format!("{TERM_CYAN}{u}{TERM_RESET}@"))
|
||||
})
|
||||
.unwrap_or("".to_string());
|
||||
let hostname = self.get_remote_hostname();
|
||||
let fmt_path = fmt_path_elide_ex(
|
||||
self.remote().wrkdir.as_path(),
|
||||
0,
|
||||
hostname.len() + 3, // 3 because of '/…/'
|
||||
);
|
||||
let fmt_path = if fmt_path.starts_with('/') {
|
||||
fmt_path
|
||||
} else {
|
||||
format!("/{}", fmt_path)
|
||||
};
|
||||
|
||||
format!("{username}{TERM_GREEN}{hostname}:{TERM_YELLOW}{fmt_path}{TERM_RESET}$ ",)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reload_remote_filelist(&mut self) {
|
||||
let width = self
|
||||
.context_mut()
|
||||
|
||||
@@ -53,7 +53,6 @@ enum Id {
|
||||
DeletePopup,
|
||||
DisconnectPopup,
|
||||
ErrorPopup,
|
||||
ExecPopup,
|
||||
ExplorerFind,
|
||||
ExplorerHostBridge,
|
||||
ExplorerRemote,
|
||||
@@ -80,6 +79,8 @@ enum Id {
|
||||
StatusBarRemote,
|
||||
SymlinkPopup,
|
||||
SyncBrowsingMkdirPopup,
|
||||
TerminalHostBridge,
|
||||
TerminalRemote,
|
||||
TransferQueueHostBridge,
|
||||
TransferQueueRemote,
|
||||
WaitPopup,
|
||||
@@ -176,7 +177,7 @@ enum UiMsg {
|
||||
ShowCopyPopup,
|
||||
ShowDeletePopup,
|
||||
ShowDisconnectPopup,
|
||||
ShowExecPopup,
|
||||
ShowTerminal,
|
||||
ShowFileInfoPopup,
|
||||
ShowFileSortingPopup,
|
||||
ShowFilterPopup,
|
||||
@@ -286,10 +287,7 @@ impl FileTransferActivity {
|
||||
log_records: VecDeque::with_capacity(256), // 256 events is enough I guess
|
||||
walkdir: WalkdirStates::default(),
|
||||
transfer: TransferStates::default(),
|
||||
cache: match TempDir::new() {
|
||||
Ok(d) => Some(d),
|
||||
Err(_) => None,
|
||||
},
|
||||
cache: TempDir::new().ok(),
|
||||
fswatcher: if enable_fs_watcher {
|
||||
FsWatcher::init(Duration::from_secs(5)).ok()
|
||||
} else {
|
||||
|
||||
@@ -1260,7 +1260,10 @@ impl FileTransferActivity {
|
||||
|
||||
/// Get total size of transfer for host_bridgehost
|
||||
fn get_total_transfer_size_host(&mut self, entry: &File) -> usize {
|
||||
if entry.is_dir() {
|
||||
// mount message to tell we are calculating size
|
||||
self.mount_blocking_wait("Calculating transfer size…");
|
||||
|
||||
let sz = if entry.is_dir() {
|
||||
// List dir
|
||||
match self.host_bridge.list_dir(entry.path()) {
|
||||
Ok(files) => files
|
||||
@@ -1281,12 +1284,18 @@ impl FileTransferActivity {
|
||||
}
|
||||
} else {
|
||||
entry.metadata.size as usize
|
||||
}
|
||||
};
|
||||
self.umount_wait();
|
||||
|
||||
sz
|
||||
}
|
||||
|
||||
/// Get total size of transfer for remote host
|
||||
fn get_total_transfer_size_remote(&mut self, entry: &File) -> usize {
|
||||
if entry.is_dir() {
|
||||
// mount message to tell we are calculating size
|
||||
self.mount_blocking_wait("Calculating transfer size…");
|
||||
|
||||
let sz = if entry.is_dir() {
|
||||
// List directory
|
||||
match self.client.list_dir(entry.path()) {
|
||||
Ok(files) => files
|
||||
@@ -1307,7 +1316,11 @@ impl FileTransferActivity {
|
||||
}
|
||||
} else {
|
||||
entry.metadata.size as usize
|
||||
}
|
||||
};
|
||||
|
||||
self.umount_wait();
|
||||
|
||||
sz
|
||||
}
|
||||
|
||||
// file changed
|
||||
|
||||
@@ -146,17 +146,12 @@ impl FileTransferActivity {
|
||||
self.update_browser_file_list()
|
||||
}
|
||||
TransferMsg::ExecuteCmd(cmd) => {
|
||||
// Exex command
|
||||
self.umount_exec();
|
||||
self.mount_blocking_wait(format!("Executing '{cmd}'…").as_str());
|
||||
// Exec command
|
||||
match self.browser.tab() {
|
||||
FileExplorerTab::HostBridge => self.action_local_exec(cmd),
|
||||
FileExplorerTab::Remote => self.action_remote_exec(cmd),
|
||||
_ => panic!("Found tab doesn't support EXEC"),
|
||||
}
|
||||
self.umount_wait();
|
||||
// Reload files
|
||||
self.update_browser_file_list()
|
||||
};
|
||||
}
|
||||
TransferMsg::GoTo(dir) => {
|
||||
match self.browser.tab() {
|
||||
@@ -417,7 +412,10 @@ impl FileTransferActivity {
|
||||
UiMsg::CloseDeletePopup => self.umount_radio_delete(),
|
||||
UiMsg::CloseDisconnectPopup => self.umount_disconnect(),
|
||||
UiMsg::CloseErrorPopup => self.umount_error(),
|
||||
UiMsg::CloseExecPopup => self.umount_exec(),
|
||||
UiMsg::CloseExecPopup => {
|
||||
self.browser.toggle_terminal(false);
|
||||
self.umount_exec();
|
||||
}
|
||||
UiMsg::CloseFatalPopup => {
|
||||
self.umount_fatal();
|
||||
self.exit_reason = Some(ExitReason::Disconnect);
|
||||
@@ -546,7 +544,10 @@ impl FileTransferActivity {
|
||||
UiMsg::ShowCopyPopup => self.mount_copy(),
|
||||
UiMsg::ShowDeletePopup => self.mount_radio_delete(),
|
||||
UiMsg::ShowDisconnectPopup => self.mount_disconnect(),
|
||||
UiMsg::ShowExecPopup => self.mount_exec(),
|
||||
UiMsg::ShowTerminal => {
|
||||
self.browser.toggle_terminal(true);
|
||||
self.mount_exec()
|
||||
}
|
||||
UiMsg::ShowFileInfoPopup if self.browser.tab() == FileExplorerTab::HostBridge => {
|
||||
if let SelectedFile::One(file) = self.get_local_selected_entries() {
|
||||
self.mount_file_info(&file);
|
||||
|
||||
@@ -158,12 +158,16 @@ impl FileTransferActivity {
|
||||
// @! Local explorer (Find or default)
|
||||
if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Local)) {
|
||||
self.app.view(&Id::ExplorerFind, f, tabs_chunks[0]);
|
||||
} else if self.browser.is_terminal_open_host_bridge() {
|
||||
self.app.view(&Id::TerminalHostBridge, f, tabs_chunks[0]);
|
||||
} else {
|
||||
self.app.view(&Id::ExplorerHostBridge, f, tabs_chunks[0]);
|
||||
}
|
||||
// @! Remote explorer (Find or default)
|
||||
if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Remote)) {
|
||||
self.app.view(&Id::ExplorerFind, f, tabs_chunks[1]);
|
||||
} else if self.browser.is_terminal_open_remote() {
|
||||
self.app.view(&Id::TerminalRemote, f, tabs_chunks[1]);
|
||||
} else {
|
||||
self.app.view(&Id::ExplorerRemote, f, tabs_chunks[1]);
|
||||
}
|
||||
@@ -238,13 +242,8 @@ impl FileTransferActivity {
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.app.view(&Id::SymlinkPopup, f, popup);
|
||||
} else if self.app.mounted(&Id::ExecPopup) {
|
||||
let popup = Popup(Size::Percentage(40), Size::Unit(3)).draw_in(f.area());
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.app.view(&Id::ExecPopup, f, popup);
|
||||
} else if self.app.mounted(&Id::FileInfoPopup) {
|
||||
let popup = Popup(Size::Percentage(50), Size::Percentage(50)).draw_in(f.area());
|
||||
let popup = Popup(Size::Percentage(80), Size::Percentage(50)).draw_in(f.area());
|
||||
f.render_widget(Clear, popup);
|
||||
// make popup
|
||||
self.app.view(&Id::FileInfoPopup, f, popup);
|
||||
@@ -570,21 +569,69 @@ impl FileTransferActivity {
|
||||
}
|
||||
|
||||
pub(super) fn mount_exec(&mut self) {
|
||||
let tab = self.browser.tab();
|
||||
let id = match tab {
|
||||
FileExplorerTab::HostBridge => Id::TerminalHostBridge,
|
||||
FileExplorerTab::Remote => Id::TerminalRemote,
|
||||
_ => panic!("Cannot mount terminal on this tab"),
|
||||
};
|
||||
|
||||
let border = match tab {
|
||||
FileExplorerTab::HostBridge => self.theme().transfer_local_explorer_highlighted,
|
||||
FileExplorerTab::Remote => self.theme().transfer_remote_explorer_highlighted,
|
||||
_ => panic!("Cannot mount terminal on this tab"),
|
||||
};
|
||||
|
||||
let input_color = self.theme().misc_input_dialog;
|
||||
assert!(
|
||||
self.app
|
||||
.remount(
|
||||
Id::ExecPopup,
|
||||
Box::new(components::ExecPopup::new(input_color)),
|
||||
id.clone(),
|
||||
Box::new(
|
||||
components::Terminal::default()
|
||||
.foreground(input_color)
|
||||
.prompt(self.terminal_prompt())
|
||||
.title(format!("Terminal - {}", self.get_tab_hostname()))
|
||||
.border_color(border)
|
||||
),
|
||||
vec![],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(self.app.active(&Id::ExecPopup).is_ok());
|
||||
assert!(self.app.active(&id).is_ok());
|
||||
}
|
||||
|
||||
/// Update the terminal prompt based on the current directory
|
||||
pub(super) fn update_terminal_prompt(&mut self) {
|
||||
let prompt = self.terminal_prompt();
|
||||
let id = match self.browser.tab() {
|
||||
FileExplorerTab::HostBridge => Id::TerminalHostBridge,
|
||||
FileExplorerTab::Remote => Id::TerminalRemote,
|
||||
_ => panic!("Cannot update terminal prompt on this tab"),
|
||||
};
|
||||
let _ = self
|
||||
.app
|
||||
.attr(&id, Attribute::Content, AttrValue::String(prompt));
|
||||
}
|
||||
|
||||
/// Print output to terminal
|
||||
pub(super) fn print_terminal(&mut self, text: String) {
|
||||
// get id
|
||||
let focus = self.app.focus().unwrap().clone();
|
||||
|
||||
// replace all \n with \r\n
|
||||
let mut text = text.replace('\n', "\r\n");
|
||||
if !text.ends_with("\r\n") && !text.is_empty() {
|
||||
text.push_str("\r\n");
|
||||
}
|
||||
let _ = self
|
||||
.app
|
||||
.attr(&focus, Attribute::Text, AttrValue::String(text));
|
||||
}
|
||||
|
||||
pub(super) fn umount_exec(&mut self) {
|
||||
let _ = self.app.umount(&Id::ExecPopup);
|
||||
let focus = self.app.focus().unwrap().clone();
|
||||
let _ = self.app.umount(&focus);
|
||||
}
|
||||
|
||||
pub(super) fn mount_find(&mut self, msg: impl ToString, fuzzy_search: bool) {
|
||||
@@ -1102,6 +1149,10 @@ impl FileTransferActivity {
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|x| {
|
||||
if x.as_payload().is_none() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
x.unwrap_payload()
|
||||
.unwrap_vec()
|
||||
.into_iter()
|
||||
@@ -1179,7 +1230,8 @@ impl FileTransferActivity {
|
||||
Id::DeletePopup,
|
||||
Id::DisconnectPopup,
|
||||
Id::ErrorPopup,
|
||||
Id::ExecPopup,
|
||||
Id::TerminalHostBridge,
|
||||
Id::TerminalRemote,
|
||||
Id::FatalPopup,
|
||||
Id::FileInfoPopup,
|
||||
Id::GotoPopup,
|
||||
|
||||
@@ -26,7 +26,7 @@ impl ErrorPopup {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(Color::Red)
|
||||
.text(&[TextSpan::from(text.as_ref())])
|
||||
.text([TextSpan::from(text.as_ref())])
|
||||
.wrap(true),
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ pub struct Footer {
|
||||
impl Default for Footer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
component: Span::default().spans(&[
|
||||
component: Span::default().spans([
|
||||
TextSpan::new("<F1|CTRL+H>").bold().fg(Color::Cyan),
|
||||
TextSpan::new(" Help "),
|
||||
TextSpan::new("<F4|CTRL+S>").bold().fg(Color::Cyan),
|
||||
@@ -88,7 +88,7 @@ impl Header {
|
||||
.color(Color::Yellow)
|
||||
.sides(BorderSides::BOTTOM),
|
||||
)
|
||||
.choices(&["Configuration parameters", "SSH Keys", "Theme"])
|
||||
.choices(["Configuration parameters", "SSH Keys", "Theme"])
|
||||
.foreground(Color::Yellow)
|
||||
.value(match layout {
|
||||
ViewLayout::SetupForm => 0,
|
||||
@@ -217,7 +217,7 @@ impl Default for QuitPopup {
|
||||
Alignment::Center,
|
||||
)
|
||||
.rewind(true)
|
||||
.choices(&["Save", "Don't save", "Cancel"]),
|
||||
.choices(["Save", "Don't save", "Cancel"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +273,7 @@ impl Default for SavePopup {
|
||||
.foreground(Color::Yellow)
|
||||
.title("Save changes?", Alignment::Center)
|
||||
.rewind(true)
|
||||
.choices(&["Yes", "No"]),
|
||||
.choices(["Yes", "No"]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ impl CheckUpdates {
|
||||
.color(Color::LightYellow)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.foreground(Color::LightYellow)
|
||||
.rewind(true)
|
||||
.title("Check for updates?", Alignment::Left)
|
||||
@@ -67,7 +67,7 @@ impl DefaultProtocol {
|
||||
.color(Color::Cyan)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "SMB", "WebDAV"])
|
||||
.choices(["SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "SMB", "WebDAV"])
|
||||
.foreground(Color::Cyan)
|
||||
.rewind(true)
|
||||
.title("Default protocol", Alignment::Left)
|
||||
@@ -110,7 +110,7 @@ impl GroupDirs {
|
||||
.color(Color::LightMagenta)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Display first", "Display last", "No"])
|
||||
.choices(["Display first", "Display last", "No"])
|
||||
.foreground(Color::LightMagenta)
|
||||
.rewind(true)
|
||||
.title("Group directories", Alignment::Left)
|
||||
@@ -148,7 +148,7 @@ impl HiddenFiles {
|
||||
.color(Color::LightRed)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.foreground(Color::LightRed)
|
||||
.rewind(true)
|
||||
.title("Show hidden files? (by default)", Alignment::Left)
|
||||
@@ -182,7 +182,7 @@ impl NotificationsEnabled {
|
||||
.color(Color::LightRed)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.foreground(Color::LightRed)
|
||||
.rewind(true)
|
||||
.title("Enable notifications?", Alignment::Left)
|
||||
@@ -216,7 +216,7 @@ impl PromptOnFileReplace {
|
||||
.color(Color::LightBlue)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.foreground(Color::LightBlue)
|
||||
.rewind(true)
|
||||
.title("Prompt when replacing existing files?", Alignment::Left)
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Default for DelSshKeyPopup {
|
||||
.color(Color::Red)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(&["Yes", "No"])
|
||||
.choices(["Yes", "No"])
|
||||
.foreground(Color::Red)
|
||||
.rewind(true)
|
||||
.title("Delete key?", Alignment::Center)
|
||||
|
||||
@@ -100,7 +100,7 @@ static REMOTE_SMB_OPT_REGEX: Lazy<Regex> =
|
||||
/**
|
||||
* Regex matches:
|
||||
* - group 1: Version
|
||||
* E.g. termscp-0.3.2 => 0.3.2; v0.4.0 => 0.4.0
|
||||
* E.g. termscp-0.3.2 => 0.3.2; v0.4.0 => 0.4.0
|
||||
*/
|
||||
static SEMVER_REGEX: Lazy<Regex> = lazy_regex!(r"v?((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))");
|
||||
|
||||
|
||||
27
themes/catppuccin-frappe.toml
Normal file
27
themes/catppuccin-frappe.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
auth_address = "#e5c890"
|
||||
auth_bookmarks = "#e5c890"
|
||||
auth_password = "#99d1db"
|
||||
auth_port = "#81c8be"
|
||||
auth_protocol = "#e5c890"
|
||||
auth_recents = "#99d1db"
|
||||
auth_username = "#ca9ee6"
|
||||
misc_error_dialog = "#e78284"
|
||||
misc_info_dialog = "#e5c890"
|
||||
misc_input_dialog = "#c6d0f5"
|
||||
misc_keys = "#8caaee"
|
||||
misc_quit_dialog = "#e5c890"
|
||||
misc_save_dialog = "#81c8be"
|
||||
misc_warn_dialog = "#ea999c"
|
||||
transfer_local_explorer_background = "#303446"
|
||||
transfer_local_explorer_foreground = "#c6d0f5"
|
||||
transfer_local_explorer_highlighted = "#e5c890"
|
||||
transfer_log_background = "#303446"
|
||||
transfer_log_window = "#e5c890"
|
||||
transfer_progress_bar_full = "##a6d189"
|
||||
transfer_progress_bar_partial = "##a6d189"
|
||||
transfer_remote_explorer_background = "#303446"
|
||||
transfer_remote_explorer_foreground = "#c6d0f5"
|
||||
transfer_remote_explorer_highlighted = "#99d1db"
|
||||
transfer_status_hidden = "#99d1db"
|
||||
transfer_status_sorting = "#e5c890"
|
||||
transfer_status_sync_browsing = "#e5c890"
|
||||
27
themes/catppuccin-latte.toml
Normal file
27
themes/catppuccin-latte.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
auth_address = "#fe640b"
|
||||
auth_bookmarks = "#179299"
|
||||
auth_password = "#1e66f5"
|
||||
auth_port = "#04a5e5"
|
||||
auth_protocol = "#179299"
|
||||
auth_recents = "#1e66f5"
|
||||
auth_username = "#ea76cb"
|
||||
misc_error_dialog = "#d20f39"
|
||||
misc_info_dialog = "#df8e1d"
|
||||
misc_input_dialog = "#4c4f69"
|
||||
misc_keys = "#209fb5"
|
||||
misc_quit_dialog = "#fe640b"
|
||||
misc_save_dialog = "#04a5e5"
|
||||
misc_warn_dialog = "#e64553"
|
||||
transfer_local_explorer_background = "#eff1f5"
|
||||
transfer_local_explorer_foreground = "#4c4f69"
|
||||
transfer_local_explorer_highlighted = "#fe640b"
|
||||
transfer_log_background = "#eff1f5"
|
||||
transfer_log_window = "#179299"
|
||||
transfer_progress_bar_full = "#40a02b"
|
||||
transfer_progress_bar_partial = "#40a02b"
|
||||
transfer_remote_explorer_background = "#eff1f5"
|
||||
transfer_remote_explorer_foreground = "#4c4f69"
|
||||
transfer_remote_explorer_highlighted = "#1e66f5"
|
||||
transfer_status_hidden = "#1e66f5"
|
||||
transfer_status_sorting = "#df8e1d"
|
||||
transfer_status_sync_browsing = "#179299"
|
||||
27
themes/catppuccin-macchiato.toml
Normal file
27
themes/catppuccin-macchiato.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
auth_address = "#f5a97f"
|
||||
auth_bookmarks = "#a6da95"
|
||||
auth_password = "#8aadf4"
|
||||
auth_port = "#8bd5ca"
|
||||
auth_protocol = "#a6da95"
|
||||
auth_recents = "#8aadf4"
|
||||
auth_username = "#f5bde6"
|
||||
misc_error_dialog = "#ed8796"
|
||||
misc_info_dialog = "#eed49f"
|
||||
misc_input_dialog = "#cad3f5"
|
||||
misc_keys = "#7dc4e4"
|
||||
misc_quit_dialog = "#f5a97f"
|
||||
misc_save_dialog = "#8bd5ca"
|
||||
misc_warn_dialog = "#ee99a0"
|
||||
transfer_local_explorer_background = "#24273a"
|
||||
transfer_local_explorer_foreground = "#cad3f5"
|
||||
transfer_local_explorer_highlighted = "#f5a97f"
|
||||
transfer_log_background = "#cad3f5"
|
||||
transfer_log_window = "#a6da95"
|
||||
transfer_progress_bar_full = "#a6da95"
|
||||
transfer_progress_bar_partial = "#a6da95"
|
||||
transfer_remote_explorer_background = "#24273a"
|
||||
transfer_remote_explorer_foreground = "#cad3f5"
|
||||
transfer_remote_explorer_highlighted = "#8aadf4"
|
||||
transfer_status_hidden = "#8aadf4"
|
||||
transfer_status_sorting = "#eed49f"
|
||||
transfer_status_sync_browsing = "#a6da95"
|
||||
27
themes/catppuccin-moka.toml
Normal file
27
themes/catppuccin-moka.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
auth_address = "#fab387"
|
||||
auth_bookmarks = "#a6e3a1"
|
||||
auth_password = "#89b4fa"
|
||||
auth_port = "#89dceb"
|
||||
auth_protocol = "#a6e3a1"
|
||||
auth_recents = "#89b4fa"
|
||||
auth_username = "#f5c2e7"
|
||||
misc_error_dialog = "#f38ba8"
|
||||
misc_info_dialog = "#f9e2af"
|
||||
misc_input_dialog = "#cdd6f4"
|
||||
misc_keys = "#74c7ec"
|
||||
misc_quit_dialog = "#fab387"
|
||||
misc_save_dialog = "#89dceb"
|
||||
misc_warn_dialog = "#eba0ac"
|
||||
transfer_local_explorer_background = "#1e1e2e"
|
||||
transfer_local_explorer_foreground = "#cdd6f4"
|
||||
transfer_local_explorer_highlighted = "#fab387"
|
||||
transfer_log_background = "#1e1e2e"
|
||||
transfer_log_window = "#a6e3a1"
|
||||
transfer_progress_bar_full = "#a6e3a1"
|
||||
transfer_progress_bar_partial = "#a6e3a1"
|
||||
transfer_remote_explorer_background = "#1e1e2e"
|
||||
transfer_remote_explorer_foreground = "#cdd6f4"
|
||||
transfer_remote_explorer_highlighted = "#89b4fa"
|
||||
transfer_status_hidden = "#89b4fa"
|
||||
transfer_status_sorting = "#f9e2af"
|
||||
transfer_status_sync_browsing = "#a6e3a1"
|
||||
Reference in New Issue
Block a user