diff --git a/CHANGELOG.md b/CHANGELOG.md index fefc81f..632fcc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,13 @@ Released on 31/07/2025 -- **Updated dependencies** and updated the Rust edition to `2024` +- 🐚 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 diff --git a/Cargo.lock b/Cargo.lock index a1f48a8..1bd3737 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,6 +105,12 @@ dependencies = [ "serde", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -3641,9 +3647,9 @@ dependencies = [ [[package]] name = "remotefs-ssh" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47fb1d79906574c1edc5b288a6bb47af8b2d50e99a7176fa731615c496d3334e" +checksum = "0835109bdc3eeeb4b77f40b6146e0442155372cd9eb741a49e7479e97ea5c746" dependencies = [ "chrono", "lazy-regex", @@ -4648,6 +4654,7 @@ dependencies = [ "tokio", "toml", "tui-realm-stdlib", + "tui-term", "tuirealm", "unicode-width 0.2.0", "uzers", @@ -5030,10 +5037,20 @@ dependencies = [ ] [[package]] -name = "tuirealm" -version = "3.0.0" +name = "tui-term" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069af2605553265d277bc559bc0e924596e82cb8b71f222314f5626cf33745d3" +checksum = "72af159125ce32b02ceaced6cffae6394b0e6b6dfd4dc164a6c59a2db9b3c0b0" +dependencies = [ + "ratatui", + "vt100", +] + +[[package]] +name = "tuirealm" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29607ee819f733f15c6bbad6aa760ed3d52b345588130003399bd792bfc6b0a8" dependencies = [ "bitflags 2.9.1", "crossterm 0.29.0", @@ -5045,9 +5062,9 @@ dependencies = [ [[package]] name = "tuirealm_derive" -version = "2.0.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8b0560f4245acc0bbe0e1d76e1f6a308145dd6e107befce4cf29e7fe32662" +checksum = "bad3c151090da5a036321776209b3d026df637d7f52c0984ff8d45927326ab4f" dependencies = [ "proc-macro2", "quote", @@ -5166,6 +5183,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.17.0" @@ -5253,6 +5276,39 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 1a2fbba..e5fc639 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ tokio = { version = "1.44", features = ["rt"] } toml = "^0.8" tui-realm-stdlib = "3" tuirealm = "3" +tui-term = "0.2" unicode-width = "^0.2" version-compare = "^0.2" whoami = "^1.6" diff --git a/README.md b/README.md index f1f97d9..2740306 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index 0d1007e..31d29ee 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -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, /// 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 diff --git a/src/filetransfer/params.rs b/src/filetransfer/params.rs index 86a4ec7..72699a1 100644 --- a/src/filetransfer/params.rs +++ b/src/filetransfer/params.rs @@ -31,6 +31,16 @@ impl HostBridgeParams { HostBridgeParams::Remote(_, params) => params, } } + + /// Returns the host name for the bridge params + pub fn username(&self) -> Option { + 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, } +impl FileTransferParams { + /// Returns the remote path if set, otherwise returns the local path + pub fn username(&self) -> Option { + self.params + .generic_params() + .and_then(|p| p.username.clone()) + } +} + /// Container for protocol params #[derive(Debug, Clone)] pub enum ProtocolParams { diff --git a/src/ui/activities/filetransfer/actions/exec.rs b/src/ui/activities/filetransfer/actions/exec.rs index 769820a..cdd16d0 100644 --- a/src/ui/activities/filetransfer/actions/exec.rs +++ b/src/ui/activities/filetransfer/actions/exec.rs @@ -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 { + 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); } } } diff --git a/src/ui/activities/filetransfer/components/mod.rs b/src/ui/activities/filetransfer/components/mod.rs index a74bb83..d47a477 100644 --- a/src/ui/activities/filetransfer/components/mod.rs +++ b/src/ui/activities/filetransfer/components/mod.rs @@ -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 { diff --git a/src/ui/activities/filetransfer/components/popups.rs b/src/ui/activities/filetransfer/components/popups.rs index 17b15ce..fb5c9f4 100644 --- a/src/ui/activities/filetransfer/components/popups.rs +++ b/src/ui/activities/filetransfer/components/popups.rs @@ -362,89 +362,6 @@ impl Component 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 for ExecPopup { - fn on(&mut self, ev: Event) -> Option { - 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, diff --git a/src/ui/activities/filetransfer/components/terminal.rs b/src/ui/activities/filetransfer/components/terminal.rs new file mode 100644 index 0000000..1a80ad4 --- /dev/null +++ b/src/ui/activities/filetransfer/components/terminal.rs @@ -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 for Terminal { + fn on(&mut self, ev: Event) -> Option { + 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, + } + } +} diff --git a/src/ui/activities/filetransfer/components/terminal/component.rs b/src/ui/activities/filetransfer/components/terminal/component.rs new file mode 100644 index 0000000..3d66533 --- /dev/null +++ b/src/ui/activities/filetransfer/components/terminal/component.rs @@ -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 { + 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); + } +} diff --git a/src/ui/activities/filetransfer/components/terminal/history.rs b/src/ui/activities/filetransfer/components/terminal/history.rs new file mode 100644 index 0000000..c61b30a --- /dev/null +++ b/src/ui/activities/filetransfer/components/terminal/history.rs @@ -0,0 +1,81 @@ +use std::collections::VecDeque; + +/// Shell history management module. +#[derive(Debug)] +pub struct History { + /// Entries in the history. + entries: VecDeque, + /// 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 { + 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 { + 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())); + } +} diff --git a/src/ui/activities/filetransfer/components/terminal/line.rs b/src/ui/activities/filetransfer/components/terminal/line.rs new file mode 100644 index 0000000..71bc226 --- /dev/null +++ b/src/ui/activities/filetransfer/components/terminal/line.rs @@ -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 ',' + } +} diff --git a/src/ui/activities/filetransfer/components/transfer/mod.rs b/src/ui/activities/filetransfer/components/transfer/mod.rs index 027ef5f..9ef347a 100644 --- a/src/ui/activities/filetransfer/components/transfer/mod.rs +++ b/src/ui/activities/filetransfer/components/transfer/mod.rs @@ -547,7 +547,7 @@ impl Component 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, @@ -761,7 +761,7 @@ impl Component 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, diff --git a/src/ui/activities/filetransfer/lib/browser.rs b/src/ui/activities/filetransfer/lib/browser.rs index cca97b6..52d7241 100644 --- a/src/ui/activities/filetransfer/lib/browser.rs +++ b/src/ui/activities/filetransfer/lib/browser.rs @@ -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); diff --git a/src/ui/activities/filetransfer/misc.rs b/src/ui/activities/filetransfer/misc.rs index 4b15908..6c0362c 100644 --- a/src/ui/activities/filetransfer/misc.rs +++ b/src/ui/activities/filetransfer/misc.rs @@ -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() diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index b31e765..3d515ed 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -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, diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 25d4db2..418e780 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -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); diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index 71ad8e9..fbe8e86 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -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,11 +242,6 @@ 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()); f.render_widget(Clear, 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,