15 Commits

Author SHA1 Message Date
Christian Visintin
4bebec369f fix: Issues with update checks (#363)
Some checks failed
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
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.

closes #354
2025-10-02 21:27:51 +02:00
Christian Visintin
05c8613279 fix: Report a message while calculating total size of files to transfer. (#362)
* fix: Report a message while calculating total size of files to transfer.

Currently, in case of huge transfers the app may look frozen while calculating the transfer size. We should at least report to the user we are actually doing something.

closes #361

* ci: windows runner
2025-10-02 20:58:26 +02:00
veeso
205d2813ad perf: Migrated to libssh.org on Linux and MacOS for better ssh agent support.
Some checks failed
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
closes #337
2025-09-20 18:13:20 +02:00
veeso
86660a0cc9 fix: SMB support for MacOS with vendored build of libsmbclient.
closes #334
2025-09-20 18:07:26 +02:00
veeso
3c79e812eb build: 0.19 deps 2025-09-06 16:40:01 +02:00
moshyfawn
0287e7706a fix: typo in file open error message (#349)
Some checks failed
Install.sh / build (push) Has been cancelled
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
2025-06-13 22:50:06 +02:00
veeso
67a14c2725 fix: lock
Some checks failed
Install.sh / build (push) Has been cancelled
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
2025-06-10 21:20:04 +02:00
veeso
df03c5c1bf feat: 0.18
Some checks failed
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
2025-06-10 14:29:02 +02:00
veeso
3ce3ffee3d fix: larger file info popup 2025-06-10 14:26:36 +02:00
Christian Visintin
c0b32a1847 feat: Replaced the Exec popup with a fully functional terminal emulator (#348)
Some checks are pending
Linux / build (push) Waiting to run
MacOS / build (push) Waiting to run
Windows / build (push) Waiting to run
* feat: Replaced the `Exec` popup with a fully functional terminal emulator

closes #340

* fix: colors and fmt for terminal

* feat: Handle exit and cd on terminal

* fix: Fmt pah
2025-06-10 13:17:20 +02:00
Christian Visintin
81ae0035c3 Fix SSH auth with id keys (#347)
I fixed the id_rsa/id_ed25519 SSH auth issue on Mac and now termscp should respect the key-based authentication, just like the regular ssh user@hostname, without the need for any ssh agents.

---

Co-authored-by: Lucas Czekaj <lukasz@czekaj.us>
2025-06-08 18:34:59 +02:00
veeso
783da22ca2 feat: **Updated dependencies** and updated the Rust edition to 2024 2025-06-08 18:00:42 +02:00
veeso
8715c2b6f9 chore: CODE_OF_CONDUCT update
Some checks failed
Install.sh / build (push) Has been cancelled
2025-05-10 19:23:06 +02:00
veeso
98a748dccc style: catppuccin themes
Some checks failed
Install.sh / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
Linux / build (push) Has been cancelled
2025-04-15 12:24:08 +02:00
Christian Visintin
bef031a414 Update website.yml
Some checks failed
Install.sh / build (push) Has been cancelled
Linux / build (push) Has been cancelled
MacOS / build (push) Has been cancelled
Windows / build (push) Has been cancelled
2025-03-23 22:01:18 +01:00
66 changed files with 2512 additions and 1143 deletions

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
TERMSCP_VERSION: "0.17.0" TERMSCP_VERSION: "0.19.0"
jobs: jobs:
build-binaries: build-binaries:
@@ -29,8 +29,45 @@ jobs:
with: with:
toolchain: stable toolchain: stable
targets: ${{ matrix.platform.target }} 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 - 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 - name: Prepare artifact files
run: | run: |
mkdir -p .artifact mkdir -p .artifact

View File

@@ -22,6 +22,13 @@ jobs:
with: with:
toolchain: stable toolchain: stable
components: rustfmt, clippy components: rustfmt, clippy
- name: Install dependencies
run: |
brew update
brew install \
pkg-config \
samba
brew link --force samba
- name: Build - name: Build
run: cargo build run: cargo build
- name: Run tests - name: Run tests

View File

@@ -33,11 +33,11 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v2 uses: actions/configure-pages@v5
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v1 uses: actions/upload-pages-artifact@v3
with: with:
path: "./site/" path: "./site/"
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v1 uses: actions/deploy-pages@v4

View File

@@ -15,7 +15,7 @@ env:
jobs: jobs:
build: build:
runs-on: windows-2019 runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View File

@@ -1,6 +1,8 @@
# Changelog # Changelog
- [Changelog](#changelog) - [Changelog](#changelog)
- [0.19.0](#0190)
- [0.18.0](#0180)
- [0.17.0](#0170) - [0.17.0](#0170)
- [0.16.1](#0161) - [0.16.1](#0161)
- [0.16.0](#0160) - [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 ## 0.17.0
Released on 24/03/2025 Released on 24/03/2025

View File

@@ -2,75 +2,131 @@
## Our Pledge ## Our Pledge
In the interest of fostering an open and welcoming environment, we as We as members, contributors, and leaders pledge to make participation in our
contributors and maintainers pledge to making participation in our project and community a harassment-free experience for everyone, regardless of age, body
our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender
size, disability, ethnicity, sex characteristics, gender identity and expression, identity and expression, level of experience, education, socio-economic status,
level of experience, education, socio-economic status, nationality, personal nationality, personal appearance, race, caste, color, religion, or sexual
appearance, race, religion, or sexual identity and orientation. identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards ## Our Standards
Examples of behavior that contributes to creating a positive environment Examples of behavior that contributes to a positive environment for our
include: community include:
* Using welcoming and inclusive language * Demonstrating empathy and kindness toward other people
* Being respectful of differing viewpoints and experiences * Being respectful of differing opinions, viewpoints, and experiences
* Gracefully accepting constructive criticism * Giving and gracefully accepting constructive feedback
* Focusing on what is best for the community * Accepting responsibility and apologizing to those affected by our mistakes,
* Showing empathy towards other community members 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 * The use of sexualized language or imagery, and sexual attention or advances of
advances any kind
* Trolling, insulting/derogatory comments, and personal or political attacks * Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment * Public or private harassment
* Publishing others' private information, such as a physical or electronic * Publishing others' private information, such as a physical or email address,
address, without explicit permission without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a * 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 Community leaders are responsible for clarifying and enforcing our standards of
behavior and are expected to take appropriate and fair corrective action in acceptable behavior and will take appropriate and fair corrective action in
response to any instances of unacceptable behavior. response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Project maintainers have the right and responsibility to remove, edit, or Community leaders have the right and responsibility to remove, edit, or reject
reject comments, commits, code, wiki edits, issues, and other contributions comments, commits, code, wiki edits, issues, and other contributions that are
that are not aligned to this Code of Conduct, or to ban temporarily or not aligned to this Code of Conduct, and will communicate reasons for moderation
permanently any contributor for other behaviors that they deem inappropriate, decisions when appropriate.
threatening, offensive, or harmful.
## Scope ## Scope
This Code of Conduct applies both within project spaces and in public spaces This Code of Conduct applies within all community spaces, and also applies when
when an individual is representing the project or its community. Examples of an individual is officially representing the community in public spaces.
representing a project or community include using an official project e-mail Examples of representing our community include using an official email address,
address, posting via an official social media account, or acting as an appointed 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 representative at an online or offline event.
further defined and clarified by project maintainers.
## Enforcement ## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at christian.visintin1997@gmail.com. All reported to the community leaders responsible for enforcement at
complaints will be reviewed and investigated and will result in a response that [INSERT CONTACT METHOD].
is deemed necessary and appropriate to the circumstances. The project team is All complaints will be reviewed and investigated promptly and fairly.
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good All community leaders are obligated to respect the privacy and security of the
faith may face temporary or permanent repercussions as determined by other reporter of any incident.
members of the project's leadership.
## 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 ## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, This Code of Conduct is adapted from the [Contributor Covenant][homepage],
available at <https://www.contributor-covenant.org/version/1/4/code-of-conduct.html> 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 [homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
For answers to common questions about this code of conduct, see [Mozilla CoC]: https://github.com/mozilla/diversity
<https://www.contributor-covenant.org/faq> [FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations

1793
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@ license = "MIT"
name = "termscp" name = "termscp"
readme = "README.md" readme = "README.md"
repository = "https://github.com/veeso/termscp" repository = "https://github.com/veeso/termscp"
version = "0.17.0" version = "0.19.0"
rust-version = "1.85.0" rust-version = "1.87.0"
[package.metadata.rpm] [package.metadata.rpm]
package = "termscp" package = "termscp"
@@ -60,6 +60,7 @@ regex = "^1"
remotefs = "^0.3" remotefs = "^0.3"
remotefs-aws-s3 = "0.4" remotefs-aws-s3 = "0.4"
remotefs-kube = "0.4" remotefs-kube = "0.4"
remotefs-smb = { version = "^0.3", optional = true }
remotefs-webdav = "^0.2" remotefs-webdav = "^0.2"
rpassword = "^7" rpassword = "^7"
self_update = { version = "^0.42", default-features = false, features = [ 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"] } serde = { version = "^1", features = ["derive"] }
simplelog = "^0.12" simplelog = "^0.12"
ssh2-config = "^0.4" ssh2-config = "^0.6"
tempfile = "3" tempfile = "3"
thiserror = "2" thiserror = "2"
tokio = { version = "1.44", features = ["rt"] } tokio = { version = "1.44", features = ["rt"] }
toml = "^0.8" toml = "^0.9"
tui-realm-stdlib = "2" tui-realm-stdlib = "3"
tuirealm = "2" tuirealm = "3"
tui-term = "0.2"
unicode-width = "^0.2" unicode-width = "^0.2"
version-compare = "^0.2" version-compare = "^0.2"
whoami = "^1.5" whoami = "^1.6"
wildmatch = "^2" wildmatch = "^2"
[target."cfg(not(target_os = \"macos\"))".dependencies]
remotefs-smb = { version = "^0.3", optional = true }
[target."cfg(target_family = \"unix\")".dependencies] [target."cfg(target_family = \"unix\")".dependencies]
remotefs-ftp = { version = "^0.2", features = ["vendored", "native-tls"] } remotefs-ftp = { version = "^0.3", features = [
remotefs-ssh = { version = "^0.6", features = ["ssh2-vendored"] } "native-tls-vendored",
"native-tls",
] }
remotefs-ssh = { version = "^0.7", default-features = false, features = [
"find",
"libssh-vendored",
] }
uzers = "0.12" uzers = "0.12"
[target."cfg(target_family = \"windows\")".dependencies] [target."cfg(target_family = \"windows\")".dependencies]
remotefs-ftp = { version = "^0.2", features = ["native-tls"] } remotefs-ftp = { version = "^0.3", features = ["native-tls"] }
remotefs-ssh = { version = "^0.6" } remotefs-ssh = { version = "^0.7" }
[dev-dependencies] [dev-dependencies]
pretty_assertions = "^1" pretty_assertions = "^1"

View File

@@ -71,7 +71,7 @@
</p> </p>
<p align="center">Developed by <a href="https://veeso.me/" target="_blank">@veeso</a></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"> <p align="center">
<a href="https://opensource.org/licenses/MIT" <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 - 📝 View and edit files with your favourite applications
- 💁 SFTP/SCP authentication with SSH keys and username/password - 💁 SFTP/SCP authentication with SSH keys and username/password
- 🐧 Compatible with Windows, Linux, FreeBSD, NetBSD and MacOS - 🐧 Compatible with Windows, Linux, FreeBSD, NetBSD and MacOS
- 🐚 Embedded terminal for executing commands on the system.
- 🎨 Make it yours! - 🎨 Make it yours!
- Themes - Themes
- Custom file explorer format - Custom file explorer format

View File

@@ -10,8 +10,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
posix: { target_family = "unix" }, posix: { target_family = "unix" },
win: { target_family = "windows" }, win: { target_family = "windows" },
// exclusive features // exclusive features
smb: { all(feature = "smb", not( macos )) }, smb: { feature = "smb" },
smb_unix: { all(unix, feature = "smb", not(macos)) }, smb_unix: { all(unix, feature = "smb") },
smb_windows: { all(windows, feature = "smb") } smb_windows: { all(windows, feature = "smb") }
} }

4
dist/build/macos.sh vendored
View File

@@ -81,7 +81,7 @@ fi
# Build release (x86_64) # Build release (x86_64)
X86_TARGET="" X86_TARGET=""
X86_TARGET_DIR="" X86_TARGET_DIR=""
if [ "$ARCH" = "aarch64" ]; then if [ "$ARCH" = "x86_64" ]; then
X86_TARGET="--target x86_64-apple-darwin" X86_TARGET="--target x86_64-apple-darwin"
X86_TARGET_DIR="target/x86_64-apple-darwin/release/" X86_TARGET_DIR="target/x86_64-apple-darwin/release/"
fi fi
@@ -92,7 +92,7 @@ RET_X86_64=$?
ARM64_TARGET="" ARM64_TARGET=""
ARM64_TARGET_DIR="" ARM64_TARGET_DIR=""
if [ "$ARCH" = "aarch64" ]; then if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
ARM64_TARGET="--target aarch64-apple-darwin" ARM64_TARGET="--target aarch64-apple-darwin"
ARM64_TARGET_DIR="target/aarch64-apple-darwin/release/" ARM64_TARGET_DIR="target/aarch64-apple-darwin/release/"
fi fi

View File

@@ -71,7 +71,7 @@
</p> </p>
<p align="center">Entwickelt von <a href="https://veeso.me/" target="_blank">@veeso</a></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"> <p align="center">
<a href="https://opensource.org/licenses/MIT" <a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p> </p>
<p align="center">Desarrollado por <a href="https://veeso.me/" target="_blank">@veeso</a></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"> <p align="center">
<a href="https://opensource.org/licenses/MIT" <a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p> </p>
<p align="center">Développé par <a href="https://veeso.me/" target="_blank">@veeso</a></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"> <p align="center">
<a href="https://opensource.org/licenses/MIT" <a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p> </p>
<p align="center">Sviluppato da <a href="https://veeso.me/" target="_blank">@veeso</a></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"> <p align="center">
<a href="https://opensource.org/licenses/MIT" <a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p> </p>
<p align="center">Desenvolvido por <a href="https://veeso.me/" target="_blank">@veeso</a></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"> <p align="center">
<a href="https://opensource.org/licenses/MIT" <a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p> </p>
<p align="center"><a href="https://veeso.me/" target="_blank">@veeso</a> 开发</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"> <p align="center">
<a href="https://opensource.org/licenses/MIT" <a href="https://opensource.org/licenses/MIT"

View File

@@ -8,7 +8,7 @@
# -f, -y, --force, --yes # -f, -y, --force, --yes
# Skip the confirmation prompt during installation # 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}" GITHUB_URL="https://github.com/veeso/termscp/releases/download/v${TERMSCP_VERSION}"
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_amd64.deb" DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_amd64.deb"
DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_arm64.deb" DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_arm64.deb"

View File

@@ -35,7 +35,7 @@
<span translate="getStarted.windows.moderation">Consider that Chocolatey moderation can take up to a few weeks <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, since last release, so if the latest version is not available yet,
you can install it downloading the ZIP file from</span> 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> target="_blank">Github</a>
<span translate="getStarted.windows.then">and then, from the ZIP directory, install it via</span> <span translate="getStarted.windows.then">and then, from the ZIP directory, install it via</span>
</p> </p>
@@ -74,7 +74,7 @@
On Debian based distros, you can install termscp using the Deb On Debian based distros, you can install termscp using the Deb
package via: package via:
</p> </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> sudo <span class="function">dpkg</span> -i <span class="string">termscp.deb</span></pre>
</div> </div>
<h3> <h3>

View File

@@ -12,7 +12,7 @@
</button> </button>
<div class="p-4 my-4 text-sm text-green-800 rounded-lg bg-green-50"> <div class="p-4 my-4 text-sm text-green-800 rounded-lg bg-green-50">
<p class="text-lg"> <p class="text-lg">
<span translate="intro.versionAlert">termscp 0.17.0 is NOW out! Download it from</span>&nbsp; <span translate="intro.versionAlert">termscp 0.19.0 is NOW out! Download it from</span>&nbsp;
<a href="/get-started.html" translate="intro.here">here!</a> <a href="/get-started.html" translate="intro.here">here!</a>
</p> </p>
</div> </div>

View File

@@ -12,7 +12,7 @@
"intro": { "intro": {
"caption": "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV", "caption": "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Get started →", "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", "here": "here",
"features": { "features": {
"handy": { "handy": {

View File

@@ -12,7 +12,7 @@
"intro": { "intro": {
"caption": "Un explorador y transferencia de archivos de terminal rico en funciones, con apoyo para SCP/SFTP/FTP/Kube/S3/WebDAV", "caption": "Un explorador y transferencia de archivos de terminal rico en funciones, con apoyo para SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Para iniciar →", "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ì", "here": "aquì",
"features": { "features": {
"handy": { "handy": {

View File

@@ -12,7 +12,7 @@
"intro": { "intro": {
"caption": "Un file transfer et navigateur de terminal riche en fonctionnalités avec support pour SCP/SFTP/FTP/Kube/S3/WebDAV", "caption": "Un file transfer et navigateur de terminal riche en fonctionnalités avec support pour SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Pour commencer →", "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", "here": "ici",
"features": { "features": {
"handy": { "handy": {

View File

@@ -12,7 +12,7 @@
"intro": { "intro": {
"caption": "Un file transfer ed explorer ricco di funzionalità con supporto per SFTP/SCP/FTP/S3", "caption": "Un file transfer ed explorer ricco di funzionalità con supporto per SFTP/SCP/FTP/S3",
"getStarted": "Installa termscp →", "getStarted": "Installa termscp →",
"versionAlert": "termscp 0.17.0 è ORA disponbile! Scaricalo da", "versionAlert": "termscp 0.19.0 è ORA disponbile! Scaricalo da",
"here": "qui", "here": "qui",
"features": { "features": {
"handy": { "handy": {

View File

@@ -12,7 +12,7 @@
"intro": { "intro": {
"caption": "功能丰富的终端 UI 文件传输和浏览器,支持 SCP/SFTP/FTP/Kube/S3/WebDAV", "caption": "功能丰富的终端 UI 文件传输和浏览器,支持 SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "开始 →", "getStarted": "开始 →",
"versionAlert": "termscp 0.17.0 现已发布! 从下载", "versionAlert": "termscp 0.19.0 现已发布! 从下载",
"here": "这里", "here": "这里",
"features": { "features": {
"handy": { "handy": {

View File

@@ -504,10 +504,7 @@ impl Formatter {
}; };
// Match format length: group 3 // Match format length: group 3
let fmt_len: Option<usize> = match &regex_match.get(3) { let fmt_len: Option<usize> = match &regex_match.get(3) {
Some(len) => match len.as_str().parse::<usize>() { Some(len) => len.as_str().parse::<usize>().ok(),
Ok(len) => Some(len),
Err(_) => None,
},
None => None, None => None,
}; };
// Match format extra: group 2 + 1 // Match format extra: group 2 + 1

View File

@@ -56,6 +56,8 @@ pub struct FileExplorer {
pub(crate) opts: ExplorerOpts, pub(crate) opts: ExplorerOpts,
/// Formatter for file entries /// Formatter for file entries
pub(crate) fmt: Formatter, pub(crate) fmt: Formatter,
/// Is terminal open for this explorer?
terminal: bool,
/// Files in directory /// Files in directory
files: Vec<File>, files: Vec<File>,
/// files enqueued for transfer. Map between source and destination /// files enqueued for transfer. Map between source and destination
@@ -73,6 +75,7 @@ impl Default for FileExplorer {
opts: ExplorerOpts::empty(), opts: ExplorerOpts::empty(),
fmt: Formatter::default(), fmt: Formatter::default(),
files: Vec::new(), files: Vec::new(),
terminal: false,
transfer_queue: HashMap::new(), transfer_queue: HashMap::new(),
} }
} }
@@ -179,6 +182,15 @@ impl FileExplorer {
self.transfer_queue.clear(); 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 // Formatting
/// Format a file entry /// Format a file entry

View File

@@ -31,6 +31,16 @@ impl HostBridgeParams {
HostBridgeParams::Remote(_, params) => params, 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 /// Holds connection parameters for file transfers
@@ -42,6 +52,15 @@ pub struct FileTransferParams {
pub local_path: Option<PathBuf>, 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 /// Container for protocol params
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ProtocolParams { pub enum ProtocolParams {

View File

@@ -13,6 +13,10 @@ use remotefs_kube::KubeMultiPodFs as KubeFs;
use remotefs_smb::SmbOptions; use remotefs_smb::SmbOptions;
#[cfg(smb)] #[cfg(smb)]
use remotefs_smb::{SmbCredentials, SmbFs}; 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_ssh::{ScpFs, SftpFs, SshAgentIdentity, SshConfigParseRule, SshOpts};
use remotefs_webdav::WebDAVFs; use remotefs_webdav::WebDAVFs;
@@ -138,12 +142,18 @@ impl RemoteFsBuilder {
} }
/// Build scp client /// 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() Self::build_ssh_opts(params, config_client).into()
} }
/// Build sftp client /// 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() Self::build_ssh_opts(params, config_client).into()
} }
@@ -233,8 +243,12 @@ impl RemoteFsBuilder {
debug!("no username was provided, using current username"); debug!("no username was provided, using current username");
opts = opts.username(whoami::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 { 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() { if let Some(config_path) = config_client.get_ssh_config() {
opts = opts.config_file( opts = opts.config_file(

View File

@@ -79,10 +79,7 @@ fn get_config_client() -> Option<ConfigClient> {
Err(_) => None, Err(_) => None,
Ok(dir) => { Ok(dir) => {
let (cfg_path, ssh_key_dir) = environment::get_config_paths(dir.as_path()); 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()) { ConfigClient::new(cfg_path.as_path(), ssh_key_dir.as_path()).ok()
Err(_) => None,
Ok(c) => Some(c),
}
} }
} }
} }

View File

@@ -2,6 +2,8 @@
//! //!
//! Automatic update module. This module is used to upgrade the current version of termscp to the latest available on Github //! 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; use self_update::backends::github::Update as GithubUpdater;
pub use self_update::errors::Error as UpdateError; pub use self_update::errors::Error as UpdateError;
use self_update::update::Release as UpdRelease; use self_update::update::Release as UpdRelease;
@@ -67,6 +69,9 @@ impl Update {
/// otherwise if no version is available, return None /// otherwise if no version is available, return None
/// In case of error returns Error with the error description /// In case of error returns Error with the error description
pub fn is_new_version_available() -> Result<Option<Release>, UpdateError> { 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..."); info!("Checking whether a new version is available...");
GithubUpdater::configure() GithubUpdater::configure()
// Set default options // Set default options
@@ -83,6 +88,27 @@ impl Update {
.map(Self::check_version) .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 /// In case received version is newer than current one, version as Some is returned; otherwise None
fn check_version(r: Release) -> Option<Release> { fn check_version(r: Release) -> Option<Release> {
debug!("got version from GitHub: {}", r.version); 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.9.9", "0.10.1"));
assert!(!Update::is_new_version_higher("0.10.9", "0.11.0")); 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());
}
} }

View File

@@ -153,10 +153,7 @@ impl ConfigClient {
// Convert string to `GroupDirs` // Convert string to `GroupDirs`
match &self.config.user_interface.group_dirs { match &self.config.user_interface.group_dirs {
None => None, None => None,
Some(val) => match GroupDirs::from_str(val.as_str()) { Some(val) => GroupDirs::from_str(val.as_str()).ok(),
Ok(val) => Some(val),
Err(_) => None,
},
} }
} }

View File

@@ -44,15 +44,29 @@ impl SshKeyStorage {
/// Resolve host via ssh2 configuration /// Resolve host via ssh2 configuration
fn resolve_host_in_ssh2_configuration(&self, host: &str) -> Option<PathBuf> { fn resolve_host_in_ssh2_configuration(&self, host: &str) -> Option<PathBuf> {
self.ssh_config.as_ref().and_then(|x| { self.ssh_config.as_ref().and_then(|x| {
let key = x x.query(host)
.query(host)
.identity_file .identity_file
.as_ref() .as_ref()
.and_then(|x| x.first().cloned()); .and_then(|x| x.first().cloned())
key
}) })
} }
/// 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 { impl SshKeyStorageTrait for SshKeyStorage {
@@ -66,9 +80,13 @@ impl SshKeyStorageTrait for SshKeyStorage {
username, host username, host
); );
// otherwise search in configuration // otherwise search in configuration
let key = self.resolve_host_in_ssh2_configuration(host)?; if let Some(key) = self.resolve_host_in_ssh2_configuration(host) {
debug!("Found key in SSH config for {host}: {}", key.display()); debug!("Found key in SSH config for {host}: {}", key.display());
Some(key) 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(), *storage.resolve("192.168.1.31", "pi").unwrap(),
exp_key_path exp_key_path
); );
// Verify unexisting key // Verify key is a default key or none
assert!(storage.resolve("deskichup", "veeso").is_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] #[test]

View File

@@ -197,7 +197,7 @@ impl DeleteBookmarkPopup {
.color(color) .color(color)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.value(1) .value(1)
.rewind(true) .rewind(true)
.foreground(color) .foreground(color)
@@ -265,7 +265,7 @@ impl DeleteRecentPopup {
.color(color) .color(color)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.value(1) .value(1)
.rewind(true) .rewind(true)
.foreground(color) .foreground(color)
@@ -337,7 +337,7 @@ impl BookmarkSavePassword {
.sides(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT) .sides(BorderSides::BOTTOM | BorderSides::LEFT | BorderSides::RIGHT)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.value(0) .value(0)
.rewind(true) .rewind(true)
.foreground(color) .foreground(color)

View File

@@ -36,9 +36,9 @@ impl RemoteProtocolRadio {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(if cfg!(smb) { .choices(if cfg!(smb) {
&["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV", "SMB"] vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV", "SMB"].into_iter()
} else { } else {
&["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV"] vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV"].into_iter()
}) })
.foreground(color) .foreground(color)
.rewind(true) .rewind(true)
@@ -126,7 +126,7 @@ impl HostBridgeProtocolRadio {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(if cfg!(smb) { .choices(if cfg!(smb) {
&[ vec![
"Localhost", "Localhost",
"SFTP", "SFTP",
"SCP", "SCP",
@@ -137,8 +137,9 @@ impl HostBridgeProtocolRadio {
"WebDAV", "WebDAV",
"SMB", "SMB",
] ]
.into_iter()
} else { } else {
&[ vec![
"Localhost", "Localhost",
"SFTP", "SFTP",
"SCP", "SCP",
@@ -148,6 +149,7 @@ impl HostBridgeProtocolRadio {
"Kube", "Kube",
"WebDAV", "WebDAV",
] ]
.into_iter()
}) })
.foreground(color) .foreground(color)
.rewind(true) .rewind(true)
@@ -649,7 +651,7 @@ impl RadioS3NewPathStyle {
.color(color) .color(color)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.foreground(color) .foreground(color)
.rewind(true) .rewind(true)
.title("New path style", Alignment::Left) .title("New path style", Alignment::Left)

View File

@@ -28,7 +28,7 @@ impl ErrorPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[TextSpan::from(text.as_ref())]) .text([TextSpan::from(text.as_ref())])
.wrap(true), .wrap(true),
} }
} }
@@ -64,7 +64,7 @@ impl InfoPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[TextSpan::from(text.as_ref())]) .text([TextSpan::from(text.as_ref())])
.wrap(true), .wrap(true),
} }
} }
@@ -100,7 +100,7 @@ impl WaitPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[TextSpan::from(text.as_ref())]) .text([TextSpan::from(text.as_ref())])
.wrap(true), .wrap(true),
} }
} }
@@ -130,7 +130,7 @@ impl WindowSizeError {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[TextSpan::from( .text([TextSpan::from(
"termscp requires at least 24 lines of height to run", "termscp requires at least 24 lines of height to run",
)]) )])
.wrap(true), .wrap(true),
@@ -163,7 +163,7 @@ impl QuitPopup {
.foreground(color) .foreground(color)
.title("Quit termscp?", Alignment::Center) .title("Quit termscp?", Alignment::Center)
.rewind(true) .rewind(true)
.choices(&["Yes", "No"]), .choices(["Yes", "No"]),
} }
} }
} }
@@ -230,7 +230,7 @@ impl InstallUpdatePopup {
.foreground(color) .foreground(color)
.title("Install update?", Alignment::Center) .title("Install update?", Alignment::Center)
.rewind(true) .rewind(true)
.choices(&["Yes", "No"]), .choices(["Yes", "No"]),
} }
} }
} }
@@ -296,13 +296,7 @@ impl ReleaseNotes {
) )
.foreground(color) .foreground(color)
.title("Release notes", Alignment::Center) .title("Release notes", Alignment::Center)
.text_rows( .text_rows(notes.lines().map(TextSpan::from)),
notes
.lines()
.map(TextSpan::from)
.collect::<Vec<TextSpan>>()
.as_slice(),
),
} }
} }
} }

View File

@@ -64,7 +64,7 @@ pub struct NewVersionDisclaimer {
impl NewVersionDisclaimer { impl NewVersionDisclaimer {
pub fn new(new_version: &str, color: Color) -> Self { pub fn new(new_version: &str, color: Color) -> Self {
Self { Self {
component: Span::default().foreground(color).spans(&[ component: Span::default().foreground(color).spans([
TextSpan::from("termscp "), TextSpan::from("termscp "),
TextSpan::new(new_version).underlined().bold(), TextSpan::new(new_version).underlined().bold(),
TextSpan::from( TextSpan::from(
@@ -91,7 +91,7 @@ pub struct HelpFooter {
impl HelpFooter { impl HelpFooter {
pub fn new(key_color: Color) -> Self { pub fn new(key_color: Color) -> Self {
Self { Self {
component: Span::default().spans(&[ component: Span::default().spans([
TextSpan::from("<F1|CTRL+H>").bold().fg(key_color), TextSpan::from("<F1|CTRL+H>").bold().fg(key_color),
TextSpan::from(" Help "), TextSpan::from(" Help "),
TextSpan::from("<CTRL+C>").bold().fg(key_color), TextSpan::from("<CTRL+C>").bold().fg(key_color),

View File

@@ -223,10 +223,7 @@ impl AuthActivity {
} }
Err(err) => { Err(err) => {
// Report error // Report error
error!("Failed to get latest version: {}", err); error!("Failed to get latest version: {err}",);
self.mount_error(
format!("Could not check for new updates: {err}").as_str(),
);
} }
} }
} else { } else {

View File

@@ -2,41 +2,127 @@
//! //!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall //! `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 // locals
use super::{FileTransferActivity, LogLevel}; 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 { impl FileTransferActivity {
pub(crate) fn action_local_exec(&mut self, input: String) { pub(crate) fn action_local_exec(&mut self, input: String) {
match self.host_bridge.exec(input.as_str()) { self.action_exec(false, input);
Ok(output) => { }
// Reload files
self.log(LogLevel::Info, format!("\"{input}\": {output}")); 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) => { Err(err) => {
// Report err self.log(LogLevel::Error, format!("Invalid command: {err}"));
self.log_and_alert( self.print_terminal(err);
LogLevel::Error, return;
format!("Could not execute command \"{input}\": {err}"), }
); };
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) { fn action_exec_exit(&mut self) {
match self.client.as_mut().exec(input.as_str()) { self.browser.toggle_terminal(false);
Ok((rc, output)) => { self.umount_exec();
// Reload files }
self.log(
LogLevel::Info, fn action_exec_cd(&mut self, remote: bool, input: String) {
format!("\"{input}\" (exitcode: {rc}): {output}"), 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) => { Err(err) => {
// Report err self.log(
self.log_and_alert(
LogLevel::Error, LogLevel::Error,
format!("Could not execute command \"{input}\": {err}"), format!("Could not execute command \"{cmd}\": {err}"),
); );
self.print_terminal(err);
} }
} }
} }

View File

@@ -186,7 +186,7 @@ impl FileTransferActivity {
Ok(_) => self.log(LogLevel::Info, format!("Opened file `{}`", p.display())), Ok(_) => self.log(LogLevel::Info, format!("Opened file `{}`", p.display())),
Err(err) => self.log( Err(err) => self.log(
LogLevel::Error, 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 // NOTE: clear screen in order to prevent crap on stderr

View File

@@ -53,12 +53,20 @@ impl MockComponent for Log {
.unwrap() .unwrap()
.unwrap_table() .unwrap_table()
.iter() .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(); .collect();
let title = ("Log".to_string(), Alignment::Left);
let w = TuiList::new(list_items) let w = TuiList::new(list_items)
.block(tui_realm_stdlib::utils::get_block( .block(tui_realm_stdlib::utils::get_block(
borders, borders,
Some(("Log".to_string(), Alignment::Left)), Some(&title),
focus, focus,
None, None,
)) ))

View File

@@ -16,7 +16,7 @@ pub struct FooterBar {
impl FooterBar { impl FooterBar {
pub fn new(key_color: Color) -> Self { pub fn new(key_color: Color) -> Self {
Self { Self {
component: Span::default().spans(&[ component: Span::default().spans([
TextSpan::from("<F1|H>").bold().fg(key_color), TextSpan::from("<F1|H>").bold().fg(key_color),
TextSpan::from(" Help "), TextSpan::from(" Help "),
TextSpan::from("<TAB>").bold().fg(key_color), TextSpan::from("<TAB>").bold().fg(key_color),

View File

@@ -13,12 +13,13 @@ mod log;
mod misc; mod misc;
mod popups; mod popups;
mod selected_files; mod selected_files;
mod terminal;
mod transfer; mod transfer;
pub use misc::FooterBar; pub use misc::FooterBar;
pub use popups::{ pub use popups::{
ATTR_FILES, ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, ExecPopup, ATTR_FILES, ChmodPopup, CopyPopup, DeletePopup, DisconnectPopup, ErrorPopup, FatalPopup,
FatalPopup, FileInfoPopup, FilterPopup, GotoPopup, KeybindingsPopup, MkdirPopup, NewfilePopup, FileInfoPopup, FilterPopup, GotoPopup, KeybindingsPopup, MkdirPopup, NewfilePopup,
OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup, OpenWithPopup, ProgressBarFull, ProgressBarPartial, QuitPopup, RenamePopup, ReplacePopup,
ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote, ReplacingFilesListPopup, SaveAsPopup, SortingPopup, StatusBarLocal, StatusBarRemote,
SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WalkdirWaitPopup, WatchedPathsList, SymlinkPopup, SyncBrowsingMkdirPopup, WaitPopup, WalkdirWaitPopup, WatchedPathsList,
@@ -28,6 +29,7 @@ pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote};
pub use self::log::Log; pub use self::log::Log;
pub use self::selected_files::SelectedFilesList; pub use self::selected_files::SelectedFilesList;
pub use self::terminal::Terminal;
#[derive(Default, MockComponent)] #[derive(Default, MockComponent)]
pub struct GlobalListener { pub struct GlobalListener {

View File

@@ -214,7 +214,7 @@ impl DeletePopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.value(1) .value(1)
.title("Delete file(s)?", Alignment::Center), .title("Delete file(s)?", Alignment::Center),
} }
@@ -279,7 +279,7 @@ impl DisconnectPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.title("Are you sure you want to disconnect?", Alignment::Center), .title("Are you sure you want to disconnect?", Alignment::Center),
} }
} }
@@ -344,7 +344,7 @@ impl ErrorPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[TextSpan::from(text.as_ref())]) .text([TextSpan::from(text.as_ref())])
.wrap(true), .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)] #[derive(MockComponent)]
pub struct FatalPopup { pub struct FatalPopup {
component: Paragraph, component: Paragraph,
@@ -461,7 +378,7 @@ impl FatalPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[TextSpan::from(text.as_ref())]) .text([TextSpan::from(text.as_ref())])
.wrap(true), .wrap(true),
} }
} }
@@ -497,6 +414,10 @@ impl FileInfoPopup {
texts texts
.add_col(TextSpan::from("Path: ")) .add_col(TextSpan::from("Path: "))
.add_col(TextSpan::new(path.as_str()).fg(Color::Yellow)); .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() { if let Some(filetype) = file.extension() {
texts texts
.add_row() .add_row()
@@ -1121,7 +1042,7 @@ impl QuitPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.title("Are you sure you want to quit termscp?", Alignment::Center), .title("Are you sure you want to quit termscp?", Alignment::Center),
} }
} }
@@ -1275,7 +1196,7 @@ impl ReplacePopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.title(text, Alignment::Center), .title(text, Alignment::Center),
} }
} }
@@ -1502,7 +1423,7 @@ impl SortingPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.choices(&["Name", "Modify time", "Creation time", "Size"]) .choices(["Name", "Modify time", "Creation time", "Size"])
.title("Sort files by…", Alignment::Center) .title("Sort files by…", Alignment::Center)
.value(match value { .value(match value {
FileSorting::CreationTime => 2, FileSorting::CreationTime => 2,
@@ -1554,7 +1475,7 @@ impl StatusBarLocal {
let file_sorting = file_sorting_label(browser.host_bridge().file_sorting); let file_sorting = file_sorting_label(browser.host_bridge().file_sorting);
let hidden_files = hidden_files_label(browser.host_bridge().hidden_files_visible()); let hidden_files = hidden_files_label(browser.host_bridge().hidden_files_visible());
Self { Self {
component: Span::default().spans(&[ component: Span::default().spans([
TextSpan::new("File sorting: ").fg(sorting_color), TextSpan::new("File sorting: ").fg(sorting_color),
TextSpan::new(file_sorting).fg(sorting_color).reversed(), TextSpan::new(file_sorting).fg(sorting_color).reversed(),
TextSpan::new(" Hidden files: ").fg(hidden_color), TextSpan::new(" Hidden files: ").fg(hidden_color),
@@ -1589,7 +1510,7 @@ impl StatusBarRemote {
false => "OFF", false => "OFF",
}; };
Self { Self {
component: Span::default().spans(&[ component: Span::default().spans([
TextSpan::new("File sorting: ").fg(sorting_color), TextSpan::new("File sorting: ").fg(sorting_color),
TextSpan::new(file_sorting).fg(sorting_color).reversed(), TextSpan::new(file_sorting).fg(sorting_color).reversed(),
TextSpan::new(" Hidden files: ").fg(hidden_color), TextSpan::new(" Hidden files: ").fg(hidden_color),
@@ -1728,7 +1649,7 @@ impl SyncBrowsingMkdirPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.title( .title(
format!( format!(
r#"Sync browsing: directory "{dir_name}" doesn't exist. Do you want to create it?"# r#"Sync browsing: directory "{dir_name}" doesn't exist. Do you want to create it?"#
@@ -1802,7 +1723,7 @@ impl WaitPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[TextSpan::from(text.as_ref())]) .text([TextSpan::from(text.as_ref())])
.wrap(true), .wrap(true),
} }
} }
@@ -1830,7 +1751,7 @@ impl WalkdirWaitPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.text(&[ .text([
TextSpan::from(text.as_ref()), TextSpan::from(text.as_ref()),
TextSpan::from("Press 'CTRL+C' to abort"), TextSpan::from("Press 'CTRL+C' to abort"),
]) ])
@@ -1961,7 +1882,7 @@ impl WatcherPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(color) .foreground(color)
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.title(text, Alignment::Center), .title(text, Alignment::Center),
} }
} }

View File

@@ -59,21 +59,21 @@ impl ChmodPopup {
}, },
user: Checkbox::default() user: Checkbox::default()
.foreground(color) .foreground(color)
.choices(&["Read", "Write", "Execute"]) .choices(["Read", "Write", "Execute"])
.title("User", Alignment::Left) .title("User", Alignment::Left)
.borders(Borders::default().sides(BorderSides::NONE)) .borders(Borders::default().sides(BorderSides::NONE))
.values(&make_pex_values(pex.user())) .values(&make_pex_values(pex.user()))
.rewind(true), .rewind(true),
group: Checkbox::default() group: Checkbox::default()
.foreground(color) .foreground(color)
.choices(&["Read", "Write", "Execute"]) .choices(["Read", "Write", "Execute"])
.title("Group", Alignment::Left) .title("Group", Alignment::Left)
.borders(Borders::default().sides(BorderSides::NONE)) .borders(Borders::default().sides(BorderSides::NONE))
.values(&make_pex_values(pex.group())) .values(&make_pex_values(pex.group()))
.rewind(true), .rewind(true),
others: Checkbox::default() others: Checkbox::default()
.foreground(color) .foreground(color)
.choices(&["Read", "Write", "Execute"]) .choices(["Read", "Write", "Execute"])
.title("Others", Alignment::Left) .title("Others", Alignment::Left)
.borders(Borders::default().sides(BorderSides::NONE)) .borders(Borders::default().sides(BorderSides::NONE))
.values(&make_pex_values(pex.others())) .values(&make_pex_values(pex.others()))
@@ -208,9 +208,11 @@ impl MockComponent for ChmodPopup {
.get_or(Attribute::Focus, AttrValue::Flag(false)) .get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag(); .unwrap_flag();
let div_title = (self.title.clone(), Alignment::Center);
let div = tui_realm_stdlib::utils::get_block( let div = tui_realm_stdlib::utils::get_block(
Borders::default().color(self.color), Borders::default().color(self.color),
Some((self.title.clone(), Alignment::Center)), Some(&div_title),
focus, focus,
None, None,
); );

View 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,
}
}
}

View 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);
}
}

View File

@@ -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()));
}
}

View 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 ','
}
}

View File

@@ -158,7 +158,7 @@ impl MockComponent for FileList {
.props .props
.get_or(Attribute::Focus, AttrValue::Flag(false)) .get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag(); .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 // Make list entries
let init_table_iter = if self.has_dot_dot() { let init_table_iter = if self.has_dot_dot() {
vec![vec![TextSpan::from("..")]] vec![vec![TextSpan::from("..")]]

View File

@@ -57,7 +57,7 @@ impl FileListWithSearch {
pub fn borders(mut self, b: Borders) -> Self { pub fn borders(mut self, b: Borders) -> Self {
self.file_list 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.search.attr(Attribute::Borders, AttrValue::Borders(b));
self self
} }

View File

@@ -28,7 +28,7 @@ impl ExplorerFuzzy {
.foreground(fg) .foreground(fg)
.highlighted_color(hg) .highlighted_color(hg)
.title(title, Alignment::Left) .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) .foreground(fg)
.highlighted_color(hg) .highlighted_color(hg)
.title(title, Alignment::Left) .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) .foreground(fg)
.highlighted_color(hg) .highlighted_color(hg)
.title(title, Alignment::Left) .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), .dot_dot(true),
} }
} }
@@ -547,7 +547,7 @@ impl Component<Msg, NoUserEvent> for ExplorerLocal {
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('x'), code: Key::Char('x'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowExecPopup)), }) => Some(Msg::Ui(UiMsg::ShowTerminal)),
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('y'), code: Key::Char('y'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
@@ -587,7 +587,7 @@ impl ExplorerRemote {
.foreground(fg) .foreground(fg)
.highlighted_color(hg) .highlighted_color(hg)
.title(title, Alignment::Left) .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), .dot_dot(true),
} }
} }
@@ -761,7 +761,7 @@ impl Component<Msg, NoUserEvent> for ExplorerRemote {
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('x'), code: Key::Char('x'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowExecPopup)), }) => Some(Msg::Ui(UiMsg::ShowTerminal)),
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('y'), code: Key::Char('y'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,

View File

@@ -148,6 +148,25 @@ impl Browser {
self.sync_browsing = !self.sync_browsing; 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 /// Build a file explorer with local host setup
pub fn build_local_explorer(cli: &ConfigClient) -> FileExplorer { pub fn build_local_explorer(cli: &ConfigClient) -> FileExplorer {
let mut builder = Self::build_explorer(cli); let mut builder = Self::build_explorer(cli);

View File

@@ -304,6 +304,70 @@ impl FileTransferActivity {
self.reload_remote_filelist(); 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) { pub(super) fn reload_remote_filelist(&mut self) {
let width = self let width = self
.context_mut() .context_mut()

View File

@@ -53,7 +53,6 @@ enum Id {
DeletePopup, DeletePopup,
DisconnectPopup, DisconnectPopup,
ErrorPopup, ErrorPopup,
ExecPopup,
ExplorerFind, ExplorerFind,
ExplorerHostBridge, ExplorerHostBridge,
ExplorerRemote, ExplorerRemote,
@@ -80,6 +79,8 @@ enum Id {
StatusBarRemote, StatusBarRemote,
SymlinkPopup, SymlinkPopup,
SyncBrowsingMkdirPopup, SyncBrowsingMkdirPopup,
TerminalHostBridge,
TerminalRemote,
TransferQueueHostBridge, TransferQueueHostBridge,
TransferQueueRemote, TransferQueueRemote,
WaitPopup, WaitPopup,
@@ -176,7 +177,7 @@ enum UiMsg {
ShowCopyPopup, ShowCopyPopup,
ShowDeletePopup, ShowDeletePopup,
ShowDisconnectPopup, ShowDisconnectPopup,
ShowExecPopup, ShowTerminal,
ShowFileInfoPopup, ShowFileInfoPopup,
ShowFileSortingPopup, ShowFileSortingPopup,
ShowFilterPopup, ShowFilterPopup,
@@ -286,10 +287,7 @@ impl FileTransferActivity {
log_records: VecDeque::with_capacity(256), // 256 events is enough I guess log_records: VecDeque::with_capacity(256), // 256 events is enough I guess
walkdir: WalkdirStates::default(), walkdir: WalkdirStates::default(),
transfer: TransferStates::default(), transfer: TransferStates::default(),
cache: match TempDir::new() { cache: TempDir::new().ok(),
Ok(d) => Some(d),
Err(_) => None,
},
fswatcher: if enable_fs_watcher { fswatcher: if enable_fs_watcher {
FsWatcher::init(Duration::from_secs(5)).ok() FsWatcher::init(Duration::from_secs(5)).ok()
} else { } else {

View File

@@ -1260,7 +1260,10 @@ impl FileTransferActivity {
/// Get total size of transfer for host_bridgehost /// Get total size of transfer for host_bridgehost
fn get_total_transfer_size_host(&mut self, entry: &File) -> usize { 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 // List dir
match self.host_bridge.list_dir(entry.path()) { match self.host_bridge.list_dir(entry.path()) {
Ok(files) => files Ok(files) => files
@@ -1281,12 +1284,18 @@ impl FileTransferActivity {
} }
} else { } else {
entry.metadata.size as usize entry.metadata.size as usize
} };
self.umount_wait();
sz
} }
/// Get total size of transfer for remote host /// Get total size of transfer for remote host
fn get_total_transfer_size_remote(&mut self, entry: &File) -> usize { 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 // List directory
match self.client.list_dir(entry.path()) { match self.client.list_dir(entry.path()) {
Ok(files) => files Ok(files) => files
@@ -1307,7 +1316,11 @@ impl FileTransferActivity {
} }
} else { } else {
entry.metadata.size as usize entry.metadata.size as usize
} };
self.umount_wait();
sz
} }
// file changed // file changed

View File

@@ -146,17 +146,12 @@ impl FileTransferActivity {
self.update_browser_file_list() self.update_browser_file_list()
} }
TransferMsg::ExecuteCmd(cmd) => { TransferMsg::ExecuteCmd(cmd) => {
// Exex command // Exec command
self.umount_exec();
self.mount_blocking_wait(format!("Executing '{cmd}'…").as_str());
match self.browser.tab() { match self.browser.tab() {
FileExplorerTab::HostBridge => self.action_local_exec(cmd), FileExplorerTab::HostBridge => self.action_local_exec(cmd),
FileExplorerTab::Remote => self.action_remote_exec(cmd), FileExplorerTab::Remote => self.action_remote_exec(cmd),
_ => panic!("Found tab doesn't support EXEC"), _ => panic!("Found tab doesn't support EXEC"),
} };
self.umount_wait();
// Reload files
self.update_browser_file_list()
} }
TransferMsg::GoTo(dir) => { TransferMsg::GoTo(dir) => {
match self.browser.tab() { match self.browser.tab() {
@@ -417,7 +412,10 @@ impl FileTransferActivity {
UiMsg::CloseDeletePopup => self.umount_radio_delete(), UiMsg::CloseDeletePopup => self.umount_radio_delete(),
UiMsg::CloseDisconnectPopup => self.umount_disconnect(), UiMsg::CloseDisconnectPopup => self.umount_disconnect(),
UiMsg::CloseErrorPopup => self.umount_error(), UiMsg::CloseErrorPopup => self.umount_error(),
UiMsg::CloseExecPopup => self.umount_exec(), UiMsg::CloseExecPopup => {
self.browser.toggle_terminal(false);
self.umount_exec();
}
UiMsg::CloseFatalPopup => { UiMsg::CloseFatalPopup => {
self.umount_fatal(); self.umount_fatal();
self.exit_reason = Some(ExitReason::Disconnect); self.exit_reason = Some(ExitReason::Disconnect);
@@ -546,7 +544,10 @@ impl FileTransferActivity {
UiMsg::ShowCopyPopup => self.mount_copy(), UiMsg::ShowCopyPopup => self.mount_copy(),
UiMsg::ShowDeletePopup => self.mount_radio_delete(), UiMsg::ShowDeletePopup => self.mount_radio_delete(),
UiMsg::ShowDisconnectPopup => self.mount_disconnect(), 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 => { UiMsg::ShowFileInfoPopup if self.browser.tab() == FileExplorerTab::HostBridge => {
if let SelectedFile::One(file) = self.get_local_selected_entries() { if let SelectedFile::One(file) = self.get_local_selected_entries() {
self.mount_file_info(&file); self.mount_file_info(&file);

View File

@@ -158,12 +158,16 @@ impl FileTransferActivity {
// @! Local explorer (Find or default) // @! Local explorer (Find or default)
if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Local)) { if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Local)) {
self.app.view(&Id::ExplorerFind, f, tabs_chunks[0]); 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 { } else {
self.app.view(&Id::ExplorerHostBridge, f, tabs_chunks[0]); self.app.view(&Id::ExplorerHostBridge, f, tabs_chunks[0]);
} }
// @! Remote explorer (Find or default) // @! Remote explorer (Find or default)
if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Remote)) { if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Remote)) {
self.app.view(&Id::ExplorerFind, f, tabs_chunks[1]); 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 { } else {
self.app.view(&Id::ExplorerRemote, f, tabs_chunks[1]); self.app.view(&Id::ExplorerRemote, f, tabs_chunks[1]);
} }
@@ -238,13 +242,8 @@ impl FileTransferActivity {
f.render_widget(Clear, popup); f.render_widget(Clear, popup);
// make popup // make popup
self.app.view(&Id::SymlinkPopup, f, 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) { } 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); f.render_widget(Clear, popup);
// make popup // make popup
self.app.view(&Id::FileInfoPopup, f, popup); self.app.view(&Id::FileInfoPopup, f, popup);
@@ -570,21 +569,69 @@ impl FileTransferActivity {
} }
pub(super) fn mount_exec(&mut self) { 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; let input_color = self.theme().misc_input_dialog;
assert!( assert!(
self.app self.app
.remount( .remount(
Id::ExecPopup, id.clone(),
Box::new(components::ExecPopup::new(input_color)), Box::new(
components::Terminal::default()
.foreground(input_color)
.prompt(self.terminal_prompt())
.title(format!("Terminal - {}", self.get_tab_hostname()))
.border_color(border)
),
vec![], vec![],
) )
.is_ok() .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) { 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) { pub(super) fn mount_find(&mut self, msg: impl ToString, fuzzy_search: bool) {
@@ -1102,6 +1149,10 @@ impl FileTransferActivity {
.ok() .ok()
.flatten() .flatten()
.map(|x| { .map(|x| {
if x.as_payload().is_none() {
return 0;
}
x.unwrap_payload() x.unwrap_payload()
.unwrap_vec() .unwrap_vec()
.into_iter() .into_iter()
@@ -1179,7 +1230,8 @@ impl FileTransferActivity {
Id::DeletePopup, Id::DeletePopup,
Id::DisconnectPopup, Id::DisconnectPopup,
Id::ErrorPopup, Id::ErrorPopup,
Id::ExecPopup, Id::TerminalHostBridge,
Id::TerminalRemote,
Id::FatalPopup, Id::FatalPopup,
Id::FileInfoPopup, Id::FileInfoPopup,
Id::GotoPopup, Id::GotoPopup,

View File

@@ -26,7 +26,7 @@ impl ErrorPopup {
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.foreground(Color::Red) .foreground(Color::Red)
.text(&[TextSpan::from(text.as_ref())]) .text([TextSpan::from(text.as_ref())])
.wrap(true), .wrap(true),
} }
} }
@@ -52,7 +52,7 @@ pub struct Footer {
impl Default for Footer { impl Default for Footer {
fn default() -> Self { fn default() -> Self {
Self { Self {
component: Span::default().spans(&[ component: Span::default().spans([
TextSpan::new("<F1|CTRL+H>").bold().fg(Color::Cyan), TextSpan::new("<F1|CTRL+H>").bold().fg(Color::Cyan),
TextSpan::new(" Help "), TextSpan::new(" Help "),
TextSpan::new("<F4|CTRL+S>").bold().fg(Color::Cyan), TextSpan::new("<F4|CTRL+S>").bold().fg(Color::Cyan),
@@ -88,7 +88,7 @@ impl Header {
.color(Color::Yellow) .color(Color::Yellow)
.sides(BorderSides::BOTTOM), .sides(BorderSides::BOTTOM),
) )
.choices(&["Configuration parameters", "SSH Keys", "Theme"]) .choices(["Configuration parameters", "SSH Keys", "Theme"])
.foreground(Color::Yellow) .foreground(Color::Yellow)
.value(match layout { .value(match layout {
ViewLayout::SetupForm => 0, ViewLayout::SetupForm => 0,
@@ -217,7 +217,7 @@ impl Default for QuitPopup {
Alignment::Center, Alignment::Center,
) )
.rewind(true) .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) .foreground(Color::Yellow)
.title("Save changes?", Alignment::Center) .title("Save changes?", Alignment::Center)
.rewind(true) .rewind(true)
.choices(&["Yes", "No"]), .choices(["Yes", "No"]),
} }
} }
} }

View File

@@ -33,7 +33,7 @@ impl CheckUpdates {
.color(Color::LightYellow) .color(Color::LightYellow)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.foreground(Color::LightYellow) .foreground(Color::LightYellow)
.rewind(true) .rewind(true)
.title("Check for updates?", Alignment::Left) .title("Check for updates?", Alignment::Left)
@@ -67,7 +67,7 @@ impl DefaultProtocol {
.color(Color::Cyan) .color(Color::Cyan)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "SMB", "WebDAV"]) .choices(["SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "SMB", "WebDAV"])
.foreground(Color::Cyan) .foreground(Color::Cyan)
.rewind(true) .rewind(true)
.title("Default protocol", Alignment::Left) .title("Default protocol", Alignment::Left)
@@ -110,7 +110,7 @@ impl GroupDirs {
.color(Color::LightMagenta) .color(Color::LightMagenta)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Display first", "Display last", "No"]) .choices(["Display first", "Display last", "No"])
.foreground(Color::LightMagenta) .foreground(Color::LightMagenta)
.rewind(true) .rewind(true)
.title("Group directories", Alignment::Left) .title("Group directories", Alignment::Left)
@@ -148,7 +148,7 @@ impl HiddenFiles {
.color(Color::LightRed) .color(Color::LightRed)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.foreground(Color::LightRed) .foreground(Color::LightRed)
.rewind(true) .rewind(true)
.title("Show hidden files? (by default)", Alignment::Left) .title("Show hidden files? (by default)", Alignment::Left)
@@ -182,7 +182,7 @@ impl NotificationsEnabled {
.color(Color::LightRed) .color(Color::LightRed)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.foreground(Color::LightRed) .foreground(Color::LightRed)
.rewind(true) .rewind(true)
.title("Enable notifications?", Alignment::Left) .title("Enable notifications?", Alignment::Left)
@@ -216,7 +216,7 @@ impl PromptOnFileReplace {
.color(Color::LightBlue) .color(Color::LightBlue)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.foreground(Color::LightBlue) .foreground(Color::LightBlue)
.rewind(true) .rewind(true)
.title("Prompt when replacing existing files?", Alignment::Left) .title("Prompt when replacing existing files?", Alignment::Left)

View File

@@ -31,7 +31,7 @@ impl Default for DelSshKeyPopup {
.color(Color::Red) .color(Color::Red)
.modifiers(BorderType::Rounded), .modifiers(BorderType::Rounded),
) )
.choices(&["Yes", "No"]) .choices(["Yes", "No"])
.foreground(Color::Red) .foreground(Color::Red)
.rewind(true) .rewind(true)
.title("Delete key?", Alignment::Center) .title("Delete key?", Alignment::Center)

View File

@@ -100,7 +100,7 @@ static REMOTE_SMB_OPT_REGEX: Lazy<Regex> =
/** /**
* Regex matches: * Regex matches:
* - group 1: Version * - 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*))"); static SEMVER_REGEX: Lazy<Regex> = lazy_regex!(r"v?((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))");

View 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"

View 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"

View 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"

View 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"