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:
env:
TERMSCP_VERSION: "0.17.0"
TERMSCP_VERSION: "0.18.0"
jobs:
build-binaries:

View File

@@ -1,6 +1,7 @@
# Changelog
- [Changelog](#changelog)
- [0.18.0](#0180)
- [0.17.0](#0170)
- [0.16.1](#0161)
- [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
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"
readme = "README.md"
repository = "https://github.com/veeso/termscp"
version = "0.17.0"
rust-version = "1.85.0"
version = "0.18.0"
rust-version = "1.85.1"
[package.metadata.rpm]
package = "termscp"
@@ -71,16 +71,17 @@ self_update = { version = "^0.42", default-features = false, features = [
] }
serde = { version = "^1", features = ["derive"] }
simplelog = "^0.12"
ssh2-config = "^0.4"
ssh2-config = "^0.5"
tempfile = "3"
thiserror = "2"
tokio = { version = "1.44", features = ["rt"] }
toml = "^0.8"
tui-realm-stdlib = "2"
tuirealm = "2"
tui-realm-stdlib = "3"
tuirealm = "3"
tui-term = "0.2"
unicode-width = "^0.2"
version-compare = "^0.2"
whoami = "^1.5"
whoami = "^1.6"
wildmatch = "^2"
[target."cfg(not(target_os = \"macos\"))".dependencies]

View File

@@ -71,7 +71,7 @@
</p>
<p align="center">Developed by <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Current version: 0.17.0 24/03/2025</p>
<p align="center">Current version: 0.18.0 10/06/2025</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"
@@ -144,6 +144,7 @@ Termscp is a feature rich terminal file transfer and explorer, with support for
- 📝 View and edit files with your favourite applications
- 💁 SFTP/SCP authentication with SSH keys and username/password
- 🐧 Compatible with Windows, Linux, FreeBSD, NetBSD and MacOS
- 🐚 Embedded terminal for executing commands on the system.
- 🎨 Make it yours!
- Themes
- Custom file explorer format

4
dist/build/macos.sh vendored
View File

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

View File

@@ -71,7 +71,7 @@
</p>
<p align="center">Entwickelt von <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Aktuelle Version: 0.17.0 24/03/2025</p>
<p align="center">Aktuelle Version: 0.18.0 10/06/2025</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p>
<p align="center">Desarrollado por <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Versión actual: 0.17.0 24/03/2025</p>
<p align="center">Versión actual: 0.18.0 10/06/2025</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p>
<p align="center">Développé par <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Version actuelle: 0.17.0 24/03/2025</p>
<p align="center">Version actuelle: 0.18.0 10/06/2025</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p>
<p align="center">Sviluppato da <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Versione corrente: 0.17.0 24/03/2025</p>
<p align="center">Versione corrente: 0.18.0 10/06/2025</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p>
<p align="center">Desenvolvido por <a href="https://veeso.me/" target="_blank">@veeso</a></p>
<p align="center">Versão atual: 0.17.0 24/03/2025</p>
<p align="center">Versão atual: 0.18.0 10/06/2025</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"

View File

@@ -71,7 +71,7 @@
</p>
<p align="center"><a href="https://veeso.me/" target="_blank">@veeso</a> 开发</p>
<p align="center">当前版本: 0.17.0 24/03/2025</p>
<p align="center">当前版本: 0.18.0 10/06/2025</p>
<p align="center">
<a href="https://opensource.org/licenses/MIT"

View File

@@ -8,7 +8,7 @@
# -f, -y, --force, --yes
# 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}"
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}_amd64.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
since last release, so if the latest version is not available yet,
you can install it downloading the ZIP file from</span>
<a href="https://github.com/veeso/termscp/releases/latest/download/termscp.0.17.0.nupkg"
<a href="https://github.com/veeso/termscp/releases/latest/download/termscp.0.18.0.nupkg"
target="_blank">Github</a>
<span translate="getStarted.windows.then">and then, from the ZIP directory, install it via</span>
</p>
@@ -74,7 +74,7 @@
On Debian based distros, you can install termscp using the Deb
package via:
</p>
<pre><span class="function">wget</span> -O termscp.deb <span class="string">https://github.com/veeso/termscp/releases/latest/download/termscp_0.17.0_amd64.deb</span>
<pre><span class="function">wget</span> -O termscp.deb <span class="string">https://github.com/veeso/termscp/releases/latest/download/termscp_0.18.0_amd64.deb</span>
sudo <span class="function">dpkg</span> -i <span class="string">termscp.deb</span></pre>
</div>
<h3>

View File

@@ -12,7 +12,7 @@
</button>
<div class="p-4 my-4 text-sm text-green-800 rounded-lg bg-green-50">
<p class="text-lg">
<span translate="intro.versionAlert">termscp 0.17.0 is NOW out! Download it from</span>&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>
</p>
</div>

View File

@@ -12,7 +12,7 @@
"intro": {
"caption": "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Get started →",
"versionAlert": "termscp 0.17.0 is NOW out! Download it from",
"versionAlert": "termscp 0.18.0 is NOW out! Download it from",
"here": "here",
"features": {
"handy": {

View File

@@ -12,7 +12,7 @@
"intro": {
"caption": "Un explorador y transferencia de archivos de terminal rico en funciones, con apoyo para SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Para iniciar →",
"versionAlert": "termscp 0.17.0 ya está disponible! Descárgalo desde",
"versionAlert": "termscp 0.18.0 ya está disponible! Descárgalo desde",
"here": "aquì",
"features": {
"handy": {

View File

@@ -12,7 +12,7 @@
"intro": {
"caption": "Un file transfer et navigateur de terminal riche en fonctionnalités avec support pour SCP/SFTP/FTP/Kube/S3/WebDAV",
"getStarted": "Pour commencer →",
"versionAlert": "termscp 0.17.0 est maintenant sorti! Télécharge-le depuis",
"versionAlert": "termscp 0.18.0 est maintenant sorti! Télécharge-le depuis",
"here": "ici",
"features": {
"handy": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,16 @@ impl HostBridgeParams {
HostBridgeParams::Remote(_, params) => params,
}
}
/// Returns the host name for the bridge params
pub fn username(&self) -> Option<String> {
match self {
HostBridgeParams::Localhost(_) => Some(whoami::username()),
HostBridgeParams::Remote(_, params) => {
params.generic_params().and_then(|p| p.username.clone())
}
}
}
}
/// Holds connection parameters for file transfers
@@ -42,6 +52,15 @@ pub struct FileTransferParams {
pub local_path: Option<PathBuf>,
}
impl FileTransferParams {
/// Returns the remote path if set, otherwise returns the local path
pub fn username(&self) -> Option<String> {
self.params
.generic_params()
.and_then(|p| p.username.clone())
}
}
/// Container for protocol params
#[derive(Debug, Clone)]
pub enum ProtocolParams {

View File

@@ -233,8 +233,12 @@ impl RemoteFsBuilder {
debug!("no username was provided, using current username");
opts = opts.username(whoami::username());
}
// For SSH protocols, only set password if explicitly provided and non-empty.
// This allows the SSH library to prioritize key-based and agent authentication.
if let Some(password) = params.password {
opts = opts.password(password);
if !password.is_empty() {
opts = opts.password(password);
}
}
if let Some(config_path) = config_client.get_ssh_config() {
opts = opts.config_file(

View File

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

View File

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

View File

@@ -44,15 +44,29 @@ impl SshKeyStorage {
/// Resolve host via ssh2 configuration
fn resolve_host_in_ssh2_configuration(&self, host: &str) -> Option<PathBuf> {
self.ssh_config.as_ref().and_then(|x| {
let key = x
.query(host)
x.query(host)
.identity_file
.as_ref()
.and_then(|x| x.first().cloned());
key
.and_then(|x| x.first().cloned())
})
}
/// Get default SSH identity files that SSH would normally try
/// This mirrors the behavior of OpenSSH client
fn get_default_identity_files(&self) -> Vec<PathBuf> {
let Some(home_dir) = dirs::home_dir() else {
return Vec::new();
};
let ssh_dir = home_dir.join(".ssh");
// Standard SSH identity files in order of preference (matches OpenSSH)
["id_ed25519", "id_ecdsa", "id_rsa", "id_dsa"]
.iter()
.map(|key_name| ssh_dir.join(key_name))
.filter(|key_path| key_path.exists())
.collect()
}
}
impl SshKeyStorageTrait for SshKeyStorage {
@@ -66,9 +80,13 @@ impl SshKeyStorageTrait for SshKeyStorage {
username, host
);
// otherwise search in configuration
let key = self.resolve_host_in_ssh2_configuration(host)?;
debug!("Found key in SSH config for {host}: {}", key.display());
Some(key)
if let Some(key) = self.resolve_host_in_ssh2_configuration(host) {
debug!("Found key in SSH config for {host}: {}", key.display());
return Some(key);
}
// As a final fallback, try default SSH identity files (like regular ssh does)
self.get_default_identity_files().into_iter().next()
}
}
@@ -137,8 +155,14 @@ mod tests {
*storage.resolve("192.168.1.31", "pi").unwrap(),
exp_key_path
);
// Verify unexisting key
assert!(storage.resolve("deskichup", "veeso").is_none());
// Verify key is a default key or none
let default_keys: Vec<PathBuf> = storage.get_default_identity_files().into_iter().collect();
if let Some(key) = storage.resolve("deskichup", "veeso") {
assert!(default_keys.contains(&key));
} else {
assert!(default_keys.is_empty());
}
}
#[test]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,12 +53,20 @@ impl MockComponent for Log {
.unwrap()
.unwrap_table()
.iter()
.map(|row| ListItem::new(tui_realm_stdlib::utils::wrap_spans(row, width, &self.props)))
.map(|row| {
let row_refs = row.iter().collect::<Vec<_>>();
ListItem::new(tui_realm_stdlib::utils::wrap_spans(
row_refs.as_slice(),
width,
&self.props,
))
})
.collect();
let title = ("Log".to_string(), Alignment::Left);
let w = TuiList::new(list_items)
.block(tui_realm_stdlib::utils::get_block(
borders,
Some(("Log".to_string(), Alignment::Left)),
Some(&title),
focus,
None,
))

View File

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

View File

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

View File

@@ -214,7 +214,7 @@ impl DeletePopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.choices(&["Yes", "No"])
.choices(["Yes", "No"])
.value(1)
.title("Delete file(s)?", Alignment::Center),
}
@@ -279,7 +279,7 @@ impl DisconnectPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.choices(&["Yes", "No"])
.choices(["Yes", "No"])
.title("Are you sure you want to disconnect?", Alignment::Center),
}
}
@@ -344,7 +344,7 @@ impl ErrorPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text(&[TextSpan::from(text.as_ref())])
.text([TextSpan::from(text.as_ref())])
.wrap(true),
}
}
@@ -362,89 +362,6 @@ impl Component<Msg, NoUserEvent> for ErrorPopup {
}
}
#[derive(MockComponent)]
pub struct ExecPopup {
component: Input,
}
impl ExecPopup {
pub fn new(color: Color) -> Self {
Self {
component: Input::default()
.borders(
Borders::default()
.color(color)
.modifiers(BorderType::Rounded),
)
.foreground(color)
.input_type(InputType::Text)
.placeholder("ps a", Style::default().fg(Color::Rgb(128, 128, 128)))
.title("Execute command", Alignment::Center),
}
}
}
impl Component<Msg, NoUserEvent> for ExecPopup {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
match ev {
Event::Keyboard(KeyEvent {
code: Key::Left, ..
}) => {
self.perform(Cmd::Move(Direction::Left));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Right, ..
}) => {
self.perform(Cmd::Move(Direction::Right));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Home, ..
}) => {
self.perform(Cmd::GoTo(Position::Begin));
Some(Msg::None)
}
Event::Keyboard(KeyEvent { code: Key::End, .. }) => {
self.perform(Cmd::GoTo(Position::End));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Delete, ..
}) => {
self.perform(Cmd::Cancel);
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Backspace,
..
}) => {
self.perform(Cmd::Delete);
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Char(ch),
..
}) => {
self.perform(Cmd::Type(ch));
Some(Msg::None)
}
Event::Keyboard(KeyEvent {
code: Key::Enter, ..
}) => match self.state() {
State::One(StateValue::String(i)) => {
Some(Msg::Transfer(TransferMsg::ExecuteCmd(i)))
}
_ => Some(Msg::None),
},
Event::Keyboard(KeyEvent { code: Key::Esc, .. }) => {
Some(Msg::Ui(UiMsg::CloseExecPopup))
}
_ => None,
}
}
}
#[derive(MockComponent)]
pub struct FatalPopup {
component: Paragraph,
@@ -461,7 +378,7 @@ impl FatalPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text(&[TextSpan::from(text.as_ref())])
.text([TextSpan::from(text.as_ref())])
.wrap(true),
}
}
@@ -497,6 +414,10 @@ impl FileInfoPopup {
texts
.add_col(TextSpan::from("Path: "))
.add_col(TextSpan::new(path.as_str()).fg(Color::Yellow));
texts
.add_row()
.add_col(TextSpan::from("Name: "))
.add_col(TextSpan::new(file.name()).fg(Color::Yellow));
if let Some(filetype) = file.extension() {
texts
.add_row()
@@ -1121,7 +1042,7 @@ impl QuitPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.choices(&["Yes", "No"])
.choices(["Yes", "No"])
.title("Are you sure you want to quit termscp?", Alignment::Center),
}
}
@@ -1275,7 +1196,7 @@ impl ReplacePopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.choices(&["Yes", "No"])
.choices(["Yes", "No"])
.title(text, Alignment::Center),
}
}
@@ -1502,7 +1423,7 @@ impl SortingPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.choices(&["Name", "Modify time", "Creation time", "Size"])
.choices(["Name", "Modify time", "Creation time", "Size"])
.title("Sort files by…", Alignment::Center)
.value(match value {
FileSorting::CreationTime => 2,
@@ -1554,7 +1475,7 @@ impl StatusBarLocal {
let file_sorting = file_sorting_label(browser.host_bridge().file_sorting);
let hidden_files = hidden_files_label(browser.host_bridge().hidden_files_visible());
Self {
component: Span::default().spans(&[
component: Span::default().spans([
TextSpan::new("File sorting: ").fg(sorting_color),
TextSpan::new(file_sorting).fg(sorting_color).reversed(),
TextSpan::new(" Hidden files: ").fg(hidden_color),
@@ -1589,7 +1510,7 @@ impl StatusBarRemote {
false => "OFF",
};
Self {
component: Span::default().spans(&[
component: Span::default().spans([
TextSpan::new("File sorting: ").fg(sorting_color),
TextSpan::new(file_sorting).fg(sorting_color).reversed(),
TextSpan::new(" Hidden files: ").fg(hidden_color),
@@ -1728,7 +1649,7 @@ impl SyncBrowsingMkdirPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.choices(&["Yes", "No"])
.choices(["Yes", "No"])
.title(
format!(
r#"Sync browsing: directory "{dir_name}" doesn't exist. Do you want to create it?"#
@@ -1802,7 +1723,7 @@ impl WaitPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text(&[TextSpan::from(text.as_ref())])
.text([TextSpan::from(text.as_ref())])
.wrap(true),
}
}
@@ -1830,7 +1751,7 @@ impl WalkdirWaitPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.text(&[
.text([
TextSpan::from(text.as_ref()),
TextSpan::from("Press 'CTRL+C' to abort"),
])
@@ -1961,7 +1882,7 @@ impl WatcherPopup {
.modifiers(BorderType::Rounded),
)
.foreground(color)
.choices(&["Yes", "No"])
.choices(["Yes", "No"])
.title(text, Alignment::Center),
}
}

View File

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

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
.get_or(Attribute::Focus, AttrValue::Flag(false))
.unwrap_flag();
let div = tui_realm_stdlib::utils::get_block(borders, Some(title), focus, None);
let div = tui_realm_stdlib::utils::get_block(borders, Some(&title), focus, None);
// Make list entries
let init_table_iter = if self.has_dot_dot() {
vec![vec![TextSpan::from("..")]]

View File

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

View File

@@ -28,7 +28,7 @@ impl ExplorerFuzzy {
.foreground(fg)
.highlighted_color(hg)
.title(title, Alignment::Left)
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect()),
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect()),
}
}
@@ -236,7 +236,7 @@ impl ExplorerFind {
.foreground(fg)
.highlighted_color(hg)
.title(title, Alignment::Left)
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect()),
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect()),
}
}
}
@@ -373,7 +373,7 @@ impl ExplorerLocal {
.foreground(fg)
.highlighted_color(hg)
.title(title, Alignment::Left)
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect())
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect())
.dot_dot(true),
}
}
@@ -547,7 +547,7 @@ impl Component<Msg, NoUserEvent> for ExplorerLocal {
Event::Keyboard(KeyEvent {
code: Key::Char('x'),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowExecPopup)),
}) => Some(Msg::Ui(UiMsg::ShowTerminal)),
Event::Keyboard(KeyEvent {
code: Key::Char('y'),
modifiers: KeyModifiers::NONE,
@@ -587,7 +587,7 @@ impl ExplorerRemote {
.foreground(fg)
.highlighted_color(hg)
.title(title, Alignment::Left)
.rows(files.iter().map(|x| vec![TextSpan::from(x)]).collect())
.rows(files.iter().map(|x| vec![TextSpan::from(*x)]).collect())
.dot_dot(true),
}
}
@@ -761,7 +761,7 @@ impl Component<Msg, NoUserEvent> for ExplorerRemote {
Event::Keyboard(KeyEvent {
code: Key::Char('x'),
modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowExecPopup)),
}) => Some(Msg::Ui(UiMsg::ShowTerminal)),
Event::Keyboard(KeyEvent {
code: Key::Char('y'),
modifiers: KeyModifiers::NONE,

View File

@@ -148,6 +148,25 @@ impl Browser {
self.sync_browsing = !self.sync_browsing;
}
/// Toggle terminal for the current tab
pub fn toggle_terminal(&mut self, terminal: bool) {
if self.tab == FileExplorerTab::HostBridge {
self.host_bridge.toggle_terminal(terminal);
} else if self.tab == FileExplorerTab::Remote {
self.remote.toggle_terminal(terminal);
}
}
/// Check if terminal is open for the host bridge tab
pub fn is_terminal_open_host_bridge(&self) -> bool {
self.tab == FileExplorerTab::HostBridge && self.host_bridge.terminal_open()
}
/// Check if terminal is open for the remote tab
pub fn is_terminal_open_remote(&self) -> bool {
self.tab == FileExplorerTab::Remote && self.remote.terminal_open()
}
/// Build a file explorer with local host setup
pub fn build_local_explorer(cli: &ConfigClient) -> FileExplorer {
let mut builder = Self::build_explorer(cli);

View File

@@ -304,6 +304,70 @@ impl FileTransferActivity {
self.reload_remote_filelist();
}
pub(super) fn get_tab_hostname(&self) -> String {
match self.browser.tab() {
FileExplorerTab::HostBridge | FileExplorerTab::FindHostBridge => {
self.get_hostbridge_hostname()
}
FileExplorerTab::Remote | FileExplorerTab::FindRemote => self.get_remote_hostname(),
}
}
pub(super) fn terminal_prompt(&self) -> String {
const TERM_CYAN: &str = "\x1b[36m";
const TERM_GREEN: &str = "\x1b[32m";
const TERM_YELLOW: &str = "\x1b[33m";
const TERM_RESET: &str = "\x1b[0m";
let panel = self.browser.tab();
match panel {
FileExplorerTab::HostBridge | FileExplorerTab::FindHostBridge => {
let username = self
.context()
.host_bridge_params()
.and_then(|params| {
params
.username()
.map(|u| format!("{TERM_CYAN}{u}{TERM_RESET}@"))
})
.unwrap_or("".to_string());
let hostname = self.get_hostbridge_hostname();
format!(
"{username}{TERM_GREEN}{hostname}:{TERM_YELLOW}{}{TERM_RESET}$ ",
fmt_path_elide_ex(
self.host_bridge().wrkdir.as_path(),
0,
hostname.len() + 3 // 3 because of '/…/'
)
)
}
FileExplorerTab::Remote | FileExplorerTab::FindRemote => {
let username = self
.context()
.remote_params()
.and_then(|params| {
params
.username()
.map(|u| format!("{TERM_CYAN}{u}{TERM_RESET}@"))
})
.unwrap_or("".to_string());
let hostname = self.get_remote_hostname();
let fmt_path = fmt_path_elide_ex(
self.remote().wrkdir.as_path(),
0,
hostname.len() + 3, // 3 because of '/…/'
);
let fmt_path = if fmt_path.starts_with('/') {
fmt_path
} else {
format!("/{}", fmt_path)
};
format!("{username}{TERM_GREEN}{hostname}:{TERM_YELLOW}{fmt_path}{TERM_RESET}$ ",)
}
}
}
pub(super) fn reload_remote_filelist(&mut self) {
let width = self
.context_mut()

View File

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

View File

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

View File

@@ -158,12 +158,16 @@ impl FileTransferActivity {
// @! Local explorer (Find or default)
if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Local)) {
self.app.view(&Id::ExplorerFind, f, tabs_chunks[0]);
} else if self.browser.is_terminal_open_host_bridge() {
self.app.view(&Id::TerminalHostBridge, f, tabs_chunks[0]);
} else {
self.app.view(&Id::ExplorerHostBridge, f, tabs_chunks[0]);
}
// @! Remote explorer (Find or default)
if matches!(self.browser.found_tab(), Some(FoundExplorerTab::Remote)) {
self.app.view(&Id::ExplorerFind, f, tabs_chunks[1]);
} else if self.browser.is_terminal_open_remote() {
self.app.view(&Id::TerminalRemote, f, tabs_chunks[1]);
} else {
self.app.view(&Id::ExplorerRemote, f, tabs_chunks[1]);
}
@@ -238,13 +242,8 @@ impl FileTransferActivity {
f.render_widget(Clear, popup);
// make popup
self.app.view(&Id::SymlinkPopup, f, popup);
} else if self.app.mounted(&Id::ExecPopup) {
let popup = Popup(Size::Percentage(40), Size::Unit(3)).draw_in(f.area());
f.render_widget(Clear, popup);
// make popup
self.app.view(&Id::ExecPopup, f, popup);
} else if self.app.mounted(&Id::FileInfoPopup) {
let popup = Popup(Size::Percentage(50), Size::Percentage(50)).draw_in(f.area());
let popup = Popup(Size::Percentage(80), Size::Percentage(50)).draw_in(f.area());
f.render_widget(Clear, popup);
// make popup
self.app.view(&Id::FileInfoPopup, f, popup);
@@ -570,21 +569,69 @@ impl FileTransferActivity {
}
pub(super) fn mount_exec(&mut self) {
let tab = self.browser.tab();
let id = match tab {
FileExplorerTab::HostBridge => Id::TerminalHostBridge,
FileExplorerTab::Remote => Id::TerminalRemote,
_ => panic!("Cannot mount terminal on this tab"),
};
let border = match tab {
FileExplorerTab::HostBridge => self.theme().transfer_local_explorer_highlighted,
FileExplorerTab::Remote => self.theme().transfer_remote_explorer_highlighted,
_ => panic!("Cannot mount terminal on this tab"),
};
let input_color = self.theme().misc_input_dialog;
assert!(
self.app
.remount(
Id::ExecPopup,
Box::new(components::ExecPopup::new(input_color)),
id.clone(),
Box::new(
components::Terminal::default()
.foreground(input_color)
.prompt(self.terminal_prompt())
.title(format!("Terminal - {}", self.get_tab_hostname()))
.border_color(border)
),
vec![],
)
.is_ok()
);
assert!(self.app.active(&Id::ExecPopup).is_ok());
assert!(self.app.active(&id).is_ok());
}
/// Update the terminal prompt based on the current directory
pub(super) fn update_terminal_prompt(&mut self) {
let prompt = self.terminal_prompt();
let id = match self.browser.tab() {
FileExplorerTab::HostBridge => Id::TerminalHostBridge,
FileExplorerTab::Remote => Id::TerminalRemote,
_ => panic!("Cannot update terminal prompt on this tab"),
};
let _ = self
.app
.attr(&id, Attribute::Content, AttrValue::String(prompt));
}
/// Print output to terminal
pub(super) fn print_terminal(&mut self, text: String) {
// get id
let focus = self.app.focus().unwrap().clone();
// replace all \n with \r\n
let mut text = text.replace('\n', "\r\n");
if !text.ends_with("\r\n") && !text.is_empty() {
text.push_str("\r\n");
}
let _ = self
.app
.attr(&focus, Attribute::Text, AttrValue::String(text));
}
pub(super) fn umount_exec(&mut self) {
let _ = self.app.umount(&Id::ExecPopup);
let focus = self.app.focus().unwrap().clone();
let _ = self.app.umount(&focus);
}
pub(super) fn mount_find(&mut self, msg: impl ToString, fuzzy_search: bool) {
@@ -1102,6 +1149,10 @@ impl FileTransferActivity {
.ok()
.flatten()
.map(|x| {
if x.as_payload().is_none() {
return 0;
}
x.unwrap_payload()
.unwrap_vec()
.into_iter()
@@ -1179,7 +1230,8 @@ impl FileTransferActivity {
Id::DeletePopup,
Id::DisconnectPopup,
Id::ErrorPopup,
Id::ExecPopup,
Id::TerminalHostBridge,
Id::TerminalRemote,
Id::FatalPopup,
Id::FileInfoPopup,
Id::GotoPopup,

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,7 @@ static REMOTE_SMB_OPT_REGEX: Lazy<Regex> =
/**
* Regex matches:
* - group 1: Version
* E.g. termscp-0.3.2 => 0.3.2; v0.4.0 => 0.4.0
* E.g. termscp-0.3.2 => 0.3.2; v0.4.0 => 0.4.0
*/
static SEMVER_REGEX: Lazy<Regex> = lazy_regex!(r"v?((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))");