6 Commits

Author SHA1 Message Date
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
53 changed files with 1865 additions and 794 deletions

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
env: env:
TERMSCP_VERSION: "0.17.0" TERMSCP_VERSION: "0.18.0"
jobs: jobs:
build-binaries: build-binaries:

View File

@@ -1,6 +1,7 @@
# Changelog # Changelog
- [Changelog](#changelog) - [Changelog](#changelog)
- [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 +41,18 @@
--- ---
## 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

1231
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.18.0"
rust-version = "1.85.0" rust-version = "1.85.1"
[package.metadata.rpm] [package.metadata.rpm]
package = "termscp" package = "termscp"
@@ -71,16 +71,17 @@ 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.5"
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.8"
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] [target."cfg(not(target_os = \"macos\"))".dependencies]

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.18.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

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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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.18.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

@@ -233,9 +233,13 @@ 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 {
if !password.is_empty() {
opts = opts.password(password); 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(
PathBuf::from(config_path), PathBuf::from(config_path),

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

@@ -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

@@ -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};
impl FileTransferActivity { /// Terminal command
pub(crate) fn action_local_exec(&mut self, input: String) { #[derive(Debug, Clone, PartialEq, Eq)]
match self.host_bridge.exec(input.as_str()) { enum Command {
Ok(output) => { Cd(String),
// Reload files Exec(String),
self.log(LogLevel::Info, format!("\"{input}\": {output}")); Exit,
} }
Err(err) => {
// Report err impl FromStr for Command {
self.log_and_alert( type Err = String;
LogLevel::Error,
format!("Could not execute command \"{input}\": {err}"), fn from_str(s: &str) -> Result<Self, Self::Err> {
); let mut parts = s.split_whitespace();
match parts.next() {
Some("cd") => {
if let Some(path) = parts.next() {
Ok(Command::Cd(path.to_string()))
} else {
Err("cd command requires a path".to_string())
}
}
Some("exit") | Some("logout") => Ok(Command::Exit),
Some(cmd) => Ok(Command::Exec(cmd.to_string())),
None => Err("".to_string()),
} }
} }
} }
impl FileTransferActivity {
pub(crate) fn action_local_exec(&mut self, input: String) {
self.action_exec(false, input);
}
pub(crate) fn action_remote_exec(&mut self, input: String) { pub(crate) fn action_remote_exec(&mut self, input: String) {
match self.client.as_mut().exec(input.as_str()) { self.action_exec(true, input);
Ok((rc, output)) => { }
// Reload files
self.log( fn action_exec(&mut self, remote: bool, cmd: String) {
LogLevel::Info, if cmd.is_empty() {
format!("\"{input}\" (exitcode: {rc}): {output}"), self.print_terminal("".to_string());
); }
let cmd = match Command::from_str(&cmd) {
Ok(cmd) => cmd,
Err(err) => {
self.log(LogLevel::Error, format!("Invalid command: {err}"));
self.print_terminal(err);
return;
}
};
match cmd {
Command::Cd(path) => {
self.action_exec_cd(remote, path);
}
Command::Exec(executable) => {
self.action_exec_executable(remote, executable);
}
Command::Exit => {
self.action_exec_exit();
}
}
}
fn action_exec_exit(&mut self) {
self.browser.toggle_terminal(false);
self.umount_exec();
}
fn action_exec_cd(&mut self, remote: bool, input: String) {
let new_dir = if remote {
let dir_path: PathBuf =
self.remote_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.remote_changedir(dir_path.as_path(), true);
dir_path
} else {
let dir_path: PathBuf =
self.host_bridge_to_abs_path(PathBuf::from(input.as_str()).as_path());
self.host_bridge_changedir(dir_path.as_path(), true);
dir_path
};
self.update_browser_file_list();
// update prompt and print the new directory
self.update_terminal_prompt();
self.print_terminal(new_dir.display().to_string());
}
/// Execute a [`Command::Exec`] command
fn action_exec_executable(&mut self, remote: bool, cmd: String) {
let res = if remote {
self.client
.as_mut()
.exec(cmd.as_str())
.map(|(_, output)| output)
.map_err(|e| e.to_string())
} else {
self.host_bridge
.exec(cmd.as_str())
.map_err(|e| e.to_string())
};
match res {
Ok(output) => {
self.print_terminal(output);
} }
Err(err) => { 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

@@ -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

@@ -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)