From fde67d13f85365d71998e59351ea0d69aece2cc9 Mon Sep 17 00:00:00 2001 From: iamazy Date: Tue, 18 Feb 2025 23:01:33 +0800 Subject: [PATCH 1/7] feat: support sftp --- crates/egui-term/examples/sftp.rs | 59 ++++++++++++++++++ crates/egui-term/src/alacritty/mod.rs | 71 +++++++++++---------- crates/egui-term/src/lib.rs | 1 + crates/egui-term/src/ssh/mod.rs | 88 +++++++++++++++------------ 4 files changed, 147 insertions(+), 72 deletions(-) create mode 100644 crates/egui-term/examples/sftp.rs diff --git a/crates/egui-term/examples/sftp.rs b/crates/egui-term/examples/sftp.rs new file mode 100644 index 0000000..a63785f --- /dev/null +++ b/crates/egui-term/examples/sftp.rs @@ -0,0 +1,59 @@ +use std::io::Read; + +use egui_term::TermError; +use tracing::{error, trace}; +use wezterm_ssh::{Config, Session, SessionEvent}; + +fn main() -> Result<(), TermError> { + let mut config = Config::new(); + config.add_default_config_files(); + let config = config.for_host("127.0.0.1"); + smol::block_on(async move { + let (session, events) = Session::connect(config)?; + + while let Ok(event) = events.recv().await { + match event { + SessionEvent::Banner(banner) => { + if let Some(banner) = banner { + trace!("{}", banner); + } + } + SessionEvent::HostVerify(verify) => { + verify.answer(true).await?; + } + SessionEvent::Authenticate(auth) => { + // login with ssh config, so no answers needed + auth.answer(vec![]).await?; + } + SessionEvent::HostVerificationFailed(failed) => { + error!("host verification failed: {failed}"); + return Err(TermError::HostVerification(failed)); + } + SessionEvent::Error(err) => { + error!("ssh login error: {err}"); + return Err(TermError::Box(err.into())); + } + SessionEvent::Authenticated => break, + } + } + + let mut exec_ret = session.exec("pwd", None).await.unwrap(); + + let mut s = String::new(); + exec_ret.stdout.read_to_string(&mut s).unwrap(); + + let sftp = session.sftp(); + match sftp.read_dir(s.trim()).await { + Ok(entries) => { + for (path, _) in entries { + println!("path: {}", path.as_path()) + } + } + Err(err) => println!("{err}"), + } + + Ok(()) + })?; + + Ok(()) +} diff --git a/crates/egui-term/src/alacritty/mod.rs b/crates/egui-term/src/alacritty/mod.rs index 44f5c70..c489bb8 100644 --- a/crates/egui-term/src/alacritty/mod.rs +++ b/crates/egui-term/src/alacritty/mod.rs @@ -1,5 +1,5 @@ use crate::errors::TermError; -use crate::ssh::{Pty, SshOptions}; +use crate::ssh::{SshOptions, SshSession}; use crate::types::Size; use alacritty_terminal::event::{Event, EventListener, Notify, OnResize, WindowSize}; use alacritty_terminal::event_loop::{EventLoop, Msg, Notifier}; @@ -138,6 +138,7 @@ pub enum TermType { pub struct Terminal { pub id: u64, + pub session: Option, pub url_regex: RegexSearch, pub term: Arc>>, pub size: TerminalSize, @@ -152,37 +153,6 @@ impl PartialEq for Terminal { } impl Terminal { - pub fn new( - id: u64, - app_context: egui::Context, - term_type: TermType, - term_size: TerminalSize, - pty_event_proxy_sender: Sender<(u64, PtyEvent)>, - ) -> Result { - match term_type { - TermType::Regular { working_directory } => { - let opts = Options { - working_directory, - ..Default::default() - }; - Self::new_with_pty( - id, - app_context, - term_size, - tty::new(&opts, term_size.into(), id)?, - pty_event_proxy_sender, - ) - } - TermType::Ssh { options } => Self::new_with_pty( - id, - app_context, - term_size, - Pty::new(options)?, - pty_event_proxy_sender, - ), - } - } - pub fn new_regular( id: u64, app_context: egui::Context, @@ -214,6 +184,42 @@ impl Terminal { ) } + fn new( + id: u64, + app_context: egui::Context, + term_type: TermType, + term_size: TerminalSize, + pty_event_proxy_sender: Sender<(u64, PtyEvent)>, + ) -> Result { + match term_type { + TermType::Regular { working_directory } => { + let opts = Options { + working_directory, + ..Default::default() + }; + Self::new_with_pty( + id, + app_context, + term_size, + tty::new(&opts, term_size.into(), id)?, + pty_event_proxy_sender, + ) + } + TermType::Ssh { options } => { + let session = SshSession::new(options)?; + let mut term = Self::new_with_pty( + id, + app_context, + term_size, + session.pty()?, + pty_event_proxy_sender, + )?; + term.session = Some(session); + Ok(term) + } + } + } + fn new_with_pty( id: u64, app_context: egui::Context, @@ -254,6 +260,7 @@ impl Terminal { debug!("create a terminal backend: {id}"); Ok(Self { id, + session: None, url_regex, term, size: term_size, diff --git a/crates/egui-term/src/lib.rs b/crates/egui-term/src/lib.rs index 26e1956..b9ce537 100644 --- a/crates/egui-term/src/lib.rs +++ b/crates/egui-term/src/lib.rs @@ -12,6 +12,7 @@ mod view; pub use alacritty::{PtyEvent, TermType, Terminal, TerminalContext}; pub use alacritty_terminal::term::TermMode; pub use bindings::{Binding, BindingAction, InputKind, KeyboardBinding}; +pub use errors::TermError; pub use font::{FontSettings, TerminalFont}; pub use ssh::{Authentication, SshOptions}; pub use theme::{ColorPalette, TerminalTheme}; diff --git a/crates/egui-term/src/ssh/mod.rs b/crates/egui-term/src/ssh/mod.rs index 2297f63..b9d2340 100644 --- a/crates/egui-term/src/ssh/mod.rs +++ b/crates/egui-term/src/ssh/mod.rs @@ -194,7 +194,9 @@ impl OnResize for Pty { } } -impl Pty { +pub struct SshSession(Session); + +impl SshSession { pub fn new(opts: SshOptions) -> Result { let mut config = Config::new(); @@ -249,47 +251,53 @@ impl Pty { SessionEvent::Authenticated => break, } } + Ok(SshSession(session)) + }) + } - // FIXME: set in settings - let mut env = HashMap::new(); - env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); - env.insert("LC_COLLATE".to_string(), "C".to_string()); - - let (pty, child) = session - .request_pty("xterm-256color", PtySize::default(), None, Some(env)) - .await?; - - #[cfg(unix)] - { - // Prepare signal handling before spawning child. - let (signals, sig_id) = { - let (sender, recv) = UnixStream::pair()?; - - // Register the recv end of the pipe for SIGCHLD. - let sig_id = pipe::register(consts::SIGCHLD, sender)?; - recv.set_nonblocking(true)?; - (recv, sig_id) - }; - - Ok(Pty { - pty, - child, - signals, - sig_id, - }) - } + pub fn pty(&self) -> Result { + // FIXME: set in settings + let mut env = HashMap::new(); + env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); + env.insert("LC_COLLATE".to_string(), "C".to_string()); - #[cfg(windows)] - { - let listener = TcpListener::bind("127.0.0.1:0")?; - let signals = TcpStream::connect(listener.local_addr()?)?; - Ok(Pty { - pty, - child, - signals, - }) - } - }) + let (pty, child) = smol::block_on(self.0.request_pty( + "xterm-256color", + PtySize::default(), + None, + Some(env), + ))?; + + #[cfg(unix)] + { + // Prepare signal handling before spawning child. + let (signals, sig_id) = { + let (sender, recv) = UnixStream::pair()?; + + // Register the recv end of the pipe for SIGCHLD. + let sig_id = pipe::register(consts::SIGCHLD, sender)?; + recv.set_nonblocking(true)?; + (recv, sig_id) + }; + + Ok(Pty { + pty, + child, + signals, + sig_id, + }) + } + + #[cfg(windows)] + { + let listener = TcpListener::bind("127.0.0.1:0")?; + let signals = TcpStream::connect(listener.local_addr()?)?; + Ok(Pty { + pty, + child, + signals, + }) + } } } From 0b9971e363a3244c56a3af81413d917de42cb3b8 Mon Sep 17 00:00:00 2001 From: iamazy Date: Tue, 18 Feb 2025 23:31:20 +0800 Subject: [PATCH 2/7] feat: support sftp --- crates/egui-term/src/alacritty/mod.rs | 2 ++ crates/egui-term/src/display/mod.rs | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/egui-term/src/alacritty/mod.rs b/crates/egui-term/src/alacritty/mod.rs index c489bb8..1a33b79 100644 --- a/crates/egui-term/src/alacritty/mod.rs +++ b/crates/egui-term/src/alacritty/mod.rs @@ -279,6 +279,7 @@ impl Drop for Terminal { pub struct TerminalContext<'a> { pub id: u64, pub terminal: MutexGuard<'a, Term>, + pub session: Option<&'a mut SshSession>, pub url_regex: &'a mut RegexSearch, pub size: &'a mut TerminalSize, pub notifier: &'a mut Notifier, @@ -292,6 +293,7 @@ impl<'a> TerminalContext<'a> { Self { id: terminal.id, terminal: term, + session: terminal.session.as_mut(), url_regex: &mut terminal.url_regex, size: &mut terminal.size, notifier: &mut terminal.notifier, diff --git a/crates/egui-term/src/display/mod.rs b/crates/egui-term/src/display/mod.rs index 56ecef8..244eb99 100644 --- a/crates/egui-term/src/display/mod.rs +++ b/crates/egui-term/src/display/mod.rs @@ -162,6 +162,13 @@ impl TerminalView<'_> { ui.separator(); // select all btn self.select_all_btn(ui, width); + + if self.term_ctx.session.is_some() { + ui.separator(); + + // sftp + self.sftp_btn(ui, width); + } }); }); } @@ -208,6 +215,14 @@ impl TerminalView<'_> { ui.close_menu(); } } + + fn sftp_btn(&mut self, ui: &mut egui::Ui, btn_width: f32) { + let sftp_btn = context_btn("Sftp", btn_width, None); + if ui.add(sftp_btn).clicked() { + self.term_ctx.select_all(); + ui.close_menu(); + } + } } fn context_btn<'a>( From 0f4277a0fffb02e6fab196f405bfb64aeb3b7097 Mon Sep 17 00:00:00 2001 From: iamazy Date: Tue, 18 Feb 2025 23:34:50 +0800 Subject: [PATCH 3/7] feat: support sftp --- crates/egui-term/src/ssh/mod.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/egui-term/src/ssh/mod.rs b/crates/egui-term/src/ssh/mod.rs index b9d2340..4234582 100644 --- a/crates/egui-term/src/ssh/mod.rs +++ b/crates/egui-term/src/ssh/mod.rs @@ -5,6 +5,7 @@ use alacritty_terminal::tty::{ChildEvent, EventedPty, EventedReadWrite}; use anyhow::Context; use polling::{Event, PollMode, Poller}; use std::collections::HashMap; +use std::ops::{Deref, DerefMut}; use std::sync::Arc; use tracing::{error, trace}; use wezterm_ssh::{ @@ -261,7 +262,7 @@ impl SshSession { env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); env.insert("LC_COLLATE".to_string(), "C".to_string()); - let (pty, child) = smol::block_on(self.0.request_pty( + let (pty, child) = smol::block_on(self.request_pty( "xterm-256color", PtySize::default(), None, @@ -301,6 +302,19 @@ impl SshSession { } } +impl Deref for SshSession { + type Target = Session; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for SshSession { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + #[derive(Debug, Clone, PartialEq)] pub struct SshOptions { pub group: String, From 8c103aac1a0afb0c7298a1b041712b254545996b Mon Sep 17 00:00:00 2001 From: iamazy Date: Wed, 19 Feb 2025 20:20:36 +0800 Subject: [PATCH 4/7] chore: remove useless `active_tab_id` --- crates/egui-term/examples/custom_bindings.rs | 6 ++---- crates/egui-term/examples/tabs.rs | 5 +---- crates/egui-term/examples/themes.rs | 6 ++---- crates/egui-term/src/view.rs | 20 +++++++++++++++----- nxshell/src/app.rs | 8 ++++---- nxshell/src/ui/tab_view/mod.rs | 2 +- 6 files changed, 25 insertions(+), 22 deletions(-) diff --git a/crates/egui-term/examples/custom_bindings.rs b/crates/egui-term/examples/custom_bindings.rs index 4bf5cd4..354ca87 100644 --- a/crates/egui-term/examples/custom_bindings.rs +++ b/crates/egui-term/examples/custom_bindings.rs @@ -1,5 +1,5 @@ use copypasta::ClipboardContext; -use egui::{Id, Key, Modifiers, Vec2}; +use egui::{Key, Modifiers, Vec2}; use egui_term::{ generate_bindings, Binding, BindingAction, InputKind, KeyboardBinding, PtyEvent, TermMode, Terminal, TerminalContext, TerminalFont, TerminalOptions, TerminalTheme, TerminalView, @@ -11,7 +11,6 @@ pub struct App { terminal_font: TerminalFont, terminal_theme: TerminalTheme, multi_exec: bool, - active_id: Option, clipboard: ClipboardContext, pty_proxy_receiver: Receiver<(u64, PtyEvent)>, custom_terminal_bindings: Vec<(Binding, BindingAction)>, @@ -67,7 +66,6 @@ impl App { terminal_theme: TerminalTheme::default(), terminal_font: TerminalFont::default(), multi_exec: false, - active_id: None, clipboard: ClipboardContext::new().unwrap(), pty_proxy_receiver, custom_terminal_bindings, @@ -89,7 +87,7 @@ impl eframe::App for App { multi_exec: &mut self.multi_exec, theme: &mut self.terminal_theme, default_font_size: 14., - active_tab_id: &mut self.active_id, + active_tab_id: None, }; let terminal = TerminalView::new(ui, term_ctx, term_opt) .set_focus(true) diff --git a/crates/egui-term/examples/tabs.rs b/crates/egui-term/examples/tabs.rs index eb30b09..65ce77f 100644 --- a/crates/egui-term/examples/tabs.rs +++ b/crates/egui-term/examples/tabs.rs @@ -1,6 +1,5 @@ use copypasta::ClipboardContext; use eframe::glow; -use egui::Id; use egui_term::{ PtyEvent, Terminal, TerminalContext, TerminalFont, TerminalOptions, TerminalTheme, TerminalView, }; @@ -14,7 +13,6 @@ pub struct App { command_receiver: Receiver<(u64, PtyEvent)>, tab_manager: TabManager, multi_exec: bool, - active_tab: Option, clipboard: ClipboardContext, } @@ -26,7 +24,6 @@ impl App { command_receiver, tab_manager: TabManager::new(), multi_exec: false, - active_tab: None, clipboard: ClipboardContext::new().unwrap(), } } @@ -79,7 +76,7 @@ impl eframe::App for App { multi_exec: &mut self.multi_exec, theme: &mut tab.theme, default_font_size: 14., - active_tab_id: &mut self.active_tab, + active_tab_id: None, }; let terminal = TerminalView::new(ui, term_ctx, term_opt) .set_focus(true) diff --git a/crates/egui-term/examples/themes.rs b/crates/egui-term/examples/themes.rs index 4082c75..3a0ad9b 100644 --- a/crates/egui-term/examples/themes.rs +++ b/crates/egui-term/examples/themes.rs @@ -1,5 +1,5 @@ use copypasta::ClipboardContext; -use egui::{Id, Vec2}; +use egui::Vec2; use egui_term::{ ColorPalette, PtyEvent, Terminal, TerminalContext, TerminalFont, TerminalOptions, TerminalTheme, TerminalView, @@ -11,7 +11,6 @@ pub struct App { terminal_font: TerminalFont, terminal_theme: TerminalTheme, multi_exec: bool, - active_id: Option, clipboard: ClipboardContext, pty_proxy_receiver: Receiver<(u64, PtyEvent)>, } @@ -25,7 +24,6 @@ impl App { Self { terminal_backend, multi_exec: false, - active_id: None, clipboard: ClipboardContext::new().unwrap(), terminal_font: TerminalFont::default(), terminal_theme: TerminalTheme::default(), @@ -104,7 +102,7 @@ impl eframe::App for App { multi_exec: &mut self.multi_exec, theme: &mut self.terminal_theme, default_font_size: 14., - active_tab_id: &mut self.active_id, + active_tab_id: None, }; let terminal = TerminalView::new(ui, term_ctx, term_opt) .set_focus(true) diff --git a/crates/egui-term/src/view.rs b/crates/egui-term/src/view.rs index e707e57..1aad835 100644 --- a/crates/egui-term/src/view.rs +++ b/crates/egui-term/src/view.rs @@ -52,7 +52,7 @@ pub struct TerminalOptions<'a> { pub font: &'a mut TerminalFont, pub multi_exec: &'a mut bool, pub theme: &'a mut TerminalTheme, - pub active_tab_id: &'a mut Option, + pub active_tab_id: Option<&'a mut Id>, } impl Widget for TerminalView<'_> { @@ -63,13 +63,20 @@ impl Widget for TerminalView<'_> { let mut state = TerminalViewState::load(ui.ctx(), widget_id); if layout.contains_pointer() { - *self.options.active_tab_id = Some(self.widget_id); + if let Some(tab_id) = self.options.active_tab_id.as_mut() { + **tab_id = self.widget_id; + } layout.ctx.set_cursor_icon(CursorIcon::Text); } else { layout.ctx.set_cursor_icon(CursorIcon::Default); } - if self.options.active_tab_id.is_none() { + if self + .options + .active_tab_id + .as_ref() + .is_some_and(|id| **id == Id::NULL) + { self.has_focus = false; } @@ -165,8 +172,11 @@ impl<'a> TerminalView<'a> { if !layout.has_focus() { return self; } - if self.options.active_tab_id != &Some(self.widget_id) && !*self.options.multi_exec { - return self; + + if let Some(tab_id) = self.options.active_tab_id.as_ref() { + if **tab_id != self.widget_id && !*self.options.multi_exec { + return self; + } } let modifiers = layout.ctx.input(|i| i.modifiers); diff --git a/nxshell/src/app.rs b/nxshell/src/app.rs index 1f5e5f5..d450d77 100644 --- a/nxshell/src/app.rs +++ b/nxshell/src/app.rs @@ -27,8 +27,8 @@ pub struct NxShellOptions { /// /// 1. When the mouse cursor leaves the terminal, it still influences the state of the current /// terminal's selection. - /// 2. When it is None, all tabs lose focus, and you can iteract with the other UI components. - pub active_tab_id: Option, + /// 2. When it is Id::NULL, all tabs lose focus, and you can iteract with the other UI components. + pub active_tab_id: Id, pub term_font: TerminalFont, pub term_font_size: f32, pub session_filter: String, @@ -36,7 +36,7 @@ pub struct NxShellOptions { impl NxShellOptions { pub fn surrender_focus(&mut self) { - self.active_tab_id = None; + self.active_tab_id = Id::NULL; } } @@ -49,7 +49,7 @@ impl Default for NxShellOptions { Self { show_add_session_modal: Rc::new(RefCell::new(false)), show_dock_panel: false, - active_tab_id: None, + active_tab_id: Id::NULL, multi_exec: false, term_font: TerminalFont::new(font_setting), term_font_size, diff --git a/nxshell/src/ui/tab_view/mod.rs b/nxshell/src/ui/tab_view/mod.rs index 7682e52..6ca2715 100644 --- a/nxshell/src/ui/tab_view/mod.rs +++ b/nxshell/src/ui/tab_view/mod.rs @@ -114,7 +114,7 @@ impl egui_dock::TabViewer for TabViewer<'_> { multi_exec: &mut self.options.multi_exec, theme: &mut tab.terminal_theme, default_font_size: self.options.term_font_size, - active_tab_id: &mut self.options.active_tab_id, + active_tab_id: Some(&mut self.options.active_tab_id), }; let terminal = TerminalView::new(ui, term_ctx, term_opt) From 1c64c1475083acf886719f1e809c71e20acad361 Mon Sep 17 00:00:00 2001 From: iamazy Date: Wed, 19 Feb 2025 20:50:11 +0800 Subject: [PATCH 5/7] fix: always copy empty string in multi exec mode --- crates/egui-term/src/input/mod.rs | 9 ++++++++- crates/egui-term/src/view.rs | 10 ---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/crates/egui-term/src/input/mod.rs b/crates/egui-term/src/input/mod.rs index e4f5bd3..98151e3 100644 --- a/crates/egui-term/src/input/mod.rs +++ b/crates/egui-term/src/input/mod.rs @@ -50,7 +50,14 @@ impl TerminalView<'_> { ))), Some(BindingAction::Copy) => { let content = self.term_ctx.selection_content(); - Some(InputAction::WriteToClipboard(content)) + + // if multi_exec is enabled, when copy selected terminal content, only one terminal content copied, + // and others copy empty content, which make copy_text in memory always be blank + if *self.options.multi_exec && content.is_empty() { + None + } else { + Some(InputAction::WriteToClipboard(content)) + } } Some(BindingAction::ResetFontSize) => { self.reset_font_size(self.options.default_font_size); diff --git a/crates/egui-term/src/view.rs b/crates/egui-term/src/view.rs index 1aad835..0c38928 100644 --- a/crates/egui-term/src/view.rs +++ b/crates/egui-term/src/view.rs @@ -218,9 +218,6 @@ impl<'a> TerminalView<'a> { modifiers, pos, } => { - if out_of_terminal(pos, layout) { - continue; - } if let Some(action) = self.button_click(state, layout, button, pos, &modifiers, pressed) { @@ -259,10 +256,3 @@ impl<'a> TerminalView<'a> { self } } - -fn out_of_terminal(pos: Pos2, layout: &Response) -> bool { - !(pos.x > layout.rect.min.x - && pos.x < layout.rect.max.x - && pos.y > layout.rect.min.y - && pos.y < layout.rect.max.y) -} From 0d8bc01c9139e9c3cdc0dff826104a497b080489 Mon Sep 17 00:00:00 2001 From: iamazy Date: Wed, 26 Feb 2025 00:42:12 +0800 Subject: [PATCH 6/7] feat: support sftp --- Cargo.toml | 6 +- crates/egui-term/Cargo.toml | 6 + crates/egui-term/examples/custom_bindings.rs | 3 +- crates/egui-term/examples/sftp.rs | 2 +- crates/egui-term/examples/tabs.rs | 3 +- crates/egui-term/examples/themes.rs | 3 +- crates/egui-term/src/alacritty/mod.rs | 24 +- crates/egui-term/src/display/mod.rs | 17 +- crates/egui-term/src/display/sftp.rs | 273 +++++++++++++++++++ crates/egui-term/src/errors.rs | 7 +- crates/egui-term/src/view.rs | 2 + nxshell/Cargo.toml | 1 - nxshell/src/ui/tab_view/mod.rs | 7 +- nxshell/src/ui/tab_view/terminal.rs | 1 + 14 files changed, 339 insertions(+), 16 deletions(-) create mode 100644 crates/egui-term/src/display/sftp.rs diff --git a/Cargo.toml b/Cargo.toml index 3b006ef..2f7a8ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ keywords = ["terminal", "egui"] [workspace.dependencies] alacritty_terminal = { git = "https://github.com/alacritty/alacritty" } anyhow = "1" +bytesize = "1" +camino = "1" catppuccin-egui = { git = "https://github.com/iamazy/catppuccin-egui", branch = "egui30" } chrono = "0.4" copypasta = "0.10" @@ -34,6 +36,7 @@ egui_form = "0.5" egui-phosphor = "0.9" egui-theme-switch = "0.2.3" egui-toast = "0.16" +file-format = "0.26" garde = "0.22" homedir = "0.3" indexmap = "2" @@ -48,10 +51,9 @@ serde = "1" signal-hook = "0.3" smol = "2" thiserror = "2" +time = "0.3" tracing = "0.1" tracing-subscriber = "0.3" -uuid = "1" -validator = "0.20" wezterm-ssh = { git = "https://github.com/iamazy/wezterm.git", branch = "nxssh" } windows = "0.59" diff --git a/crates/egui-term/Cargo.toml b/crates/egui-term/Cargo.toml index e5edf22..0380bc1 100644 --- a/crates/egui-term/Cargo.toml +++ b/crates/egui-term/Cargo.toml @@ -13,13 +13,19 @@ keywords.workspace = true [dependencies] alacritty_terminal.workspace = true anyhow.workspace = true +bytesize.workspace = true +camino.workspace = true copypasta.workspace = true egui.workspace = true +egui_extras.workspace = true +file-format.workspace = true +homedir.workspace = true open.workspace = true parking_lot.workspace = true polling.workspace = true smol.workspace = true thiserror.workspace = true +time.workspace = true tracing.workspace = true wezterm-ssh = { workspace = true, features = ["vendored-openssl"] } diff --git a/crates/egui-term/examples/custom_bindings.rs b/crates/egui-term/examples/custom_bindings.rs index 354ca87..34a527c 100644 --- a/crates/egui-term/examples/custom_bindings.rs +++ b/crates/egui-term/examples/custom_bindings.rs @@ -81,7 +81,8 @@ impl eframe::App for App { } egui::CentralPanel::default().show(ctx, |ui| { - let term_ctx = TerminalContext::new(&mut self.terminal_backend, &mut self.clipboard); + let term_ctx = + TerminalContext::new(&mut self.terminal_backend, &mut self.clipboard, &mut false); let term_opt = TerminalOptions { font: &mut self.terminal_font, multi_exec: &mut self.multi_exec, diff --git a/crates/egui-term/examples/sftp.rs b/crates/egui-term/examples/sftp.rs index a63785f..ce96f3c 100644 --- a/crates/egui-term/examples/sftp.rs +++ b/crates/egui-term/examples/sftp.rs @@ -45,7 +45,7 @@ fn main() -> Result<(), TermError> { let sftp = session.sftp(); match sftp.read_dir(s.trim()).await { Ok(entries) => { - for (path, _) in entries { + for (path, _meta) in entries { println!("path: {}", path.as_path()) } } diff --git a/crates/egui-term/examples/tabs.rs b/crates/egui-term/examples/tabs.rs index 65ce77f..3bc0306 100644 --- a/crates/egui-term/examples/tabs.rs +++ b/crates/egui-term/examples/tabs.rs @@ -70,7 +70,8 @@ impl eframe::App for App { egui::CentralPanel::default().show(ctx, |ui| { if let Some(tab) = self.tab_manager.get_active() { - let term_ctx = TerminalContext::new(&mut tab.backend, &mut self.clipboard); + let term_ctx = + TerminalContext::new(&mut tab.backend, &mut self.clipboard, &mut false); let term_opt = TerminalOptions { font: &mut tab.font, multi_exec: &mut self.multi_exec, diff --git a/crates/egui-term/examples/themes.rs b/crates/egui-term/examples/themes.rs index 3a0ad9b..56f3344 100644 --- a/crates/egui-term/examples/themes.rs +++ b/crates/egui-term/examples/themes.rs @@ -96,7 +96,8 @@ impl eframe::App for App { }); egui::CentralPanel::default().show(ctx, |ui| { - let term_ctx = TerminalContext::new(&mut self.terminal_backend, &mut self.clipboard); + let term_ctx = + TerminalContext::new(&mut self.terminal_backend, &mut self.clipboard, &mut false); let term_opt = TerminalOptions { font: &mut self.terminal_font, multi_exec: &mut self.multi_exec, diff --git a/crates/egui-term/src/alacritty/mod.rs b/crates/egui-term/src/alacritty/mod.rs index 1a33b79..90d0f2f 100644 --- a/crates/egui-term/src/alacritty/mod.rs +++ b/crates/egui-term/src/alacritty/mod.rs @@ -1,3 +1,4 @@ +use crate::display::SftpExplorer; use crate::errors::TermError; use crate::ssh::{SshOptions, SshSession}; use crate::types::Size; @@ -22,6 +23,7 @@ use std::path::PathBuf; use std::sync::mpsc::Sender; use std::sync::{mpsc, Arc}; use tracing::debug; +use wezterm_ssh::Session; pub type PtyEvent = Event; @@ -139,6 +141,7 @@ pub enum TermType { pub struct Terminal { pub id: u64, pub session: Option, + pub sftp_explorer: Option, pub url_regex: RegexSearch, pub term: Arc>>, pub size: TerminalSize, @@ -261,6 +264,7 @@ impl Terminal { Ok(Self { id, session: None, + sftp_explorer: None, url_regex, term, size: term_size, @@ -279,29 +283,43 @@ impl Drop for Terminal { pub struct TerminalContext<'a> { pub id: u64, pub terminal: MutexGuard<'a, Term>, - pub session: Option<&'a mut SshSession>, + pub session: Option<&'a SshSession>, + pub sftp_explorer: &'a mut Option, pub url_regex: &'a mut RegexSearch, pub size: &'a mut TerminalSize, pub notifier: &'a mut Notifier, pub hovered_hyperlink: &'a mut Option, pub clipboard: &'a mut ClipboardContext, + pub show_sftp_window: &'a mut bool, } impl<'a> TerminalContext<'a> { - pub fn new(terminal: &'a mut Terminal, clipboard: &'a mut ClipboardContext) -> Self { + pub fn new( + terminal: &'a mut Terminal, + clipboard: &'a mut ClipboardContext, + show_sftp_window: &'a mut bool, + ) -> Self { let term = terminal.term.lock(); Self { id: terminal.id, terminal: term, - session: terminal.session.as_mut(), + session: terminal.session.as_ref(), + sftp_explorer: &mut terminal.sftp_explorer, url_regex: &mut terminal.url_regex, size: &mut terminal.size, notifier: &mut terminal.notifier, hovered_hyperlink: &mut terminal.hovered_hyperlink, + show_sftp_window, clipboard, } } + pub fn open_sftp(&mut self, session: &Session) -> Result<(), TermError> { + let sftp = session.sftp(); + *self.sftp_explorer = Some(SftpExplorer::new(sftp)?); + Ok(()) + } + pub fn term_mode(&self) -> TermMode { *self.terminal.mode() } diff --git a/crates/egui-term/src/display/mod.rs b/crates/egui-term/src/display/mod.rs index 244eb99..046c5d2 100644 --- a/crates/egui-term/src/display/mod.rs +++ b/crates/egui-term/src/display/mod.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] mod color; +mod sftp; +pub use sftp::SftpExplorer; use crate::display::color::HOVERED_HYPERLINK_COLOR; use crate::view::TerminalViewState; @@ -15,6 +17,8 @@ use egui::{ Painter, Pos2, Rect, Response, Vec2, WidgetText, }; use egui::{Shape, Stroke}; +use tracing::error; +use wezterm_ssh::Session; impl TerminalView<'_> { pub fn show(self, state: &mut TerminalViewState, layout: &Response, painter: &Painter) { @@ -163,11 +167,11 @@ impl TerminalView<'_> { // select all btn self.select_all_btn(ui, width); - if self.term_ctx.session.is_some() { + if let Some(session) = self.term_ctx.session { ui.separator(); // sftp - self.sftp_btn(ui, width); + self.sftp_btn(session, ui, width); } }); }); @@ -216,10 +220,15 @@ impl TerminalView<'_> { } } - fn sftp_btn(&mut self, ui: &mut egui::Ui, btn_width: f32) { + fn sftp_btn(&mut self, session: &Session, ui: &mut egui::Ui, btn_width: f32) { let sftp_btn = context_btn("Sftp", btn_width, None); if ui.add(sftp_btn).clicked() { - self.term_ctx.select_all(); + match self.term_ctx.open_sftp(session) { + Ok(_) => { + *self.term_ctx.show_sftp_window = true; + } + Err(err) => error!("opening sftp error: {err}"), + } ui.close_menu(); } } diff --git a/crates/egui-term/src/display/sftp.rs b/crates/egui-term/src/display/sftp.rs new file mode 100644 index 0000000..279c339 --- /dev/null +++ b/crates/egui-term/src/display/sftp.rs @@ -0,0 +1,273 @@ +use crate::{TermError, TerminalView}; +use camino::Utf8PathBuf; +use egui::{Align2, CentralPanel, Context, Layout, TopBottomPanel, Window}; +use egui_extras::TableBuilder; +use file_format::FileFormat; +use homedir::my_home; +use time::Duration; +use wezterm_ssh::{FilePermissions, FileType, Metadata, Sftp}; + +pub struct Entry { + pub path: Utf8PathBuf, + meta: Metadata, +} + +pub struct SftpExplorer { + pub sftp: Sftp, + pub current_path: String, + pub entries: Vec, + previous_path: Vec, + forward_path: Vec, +} + +impl SftpExplorer { + pub fn new(sftp: Sftp) -> Result { + let current_path = match my_home()? { + Some(home) => home, + None => { + return Err(TermError::Any(anyhow::anyhow!( + "cannot find home directory" + ))) + } + }; + let current_path = match current_path.to_str() { + Some(path) => path.to_owned(), + None => { + return Err(TermError::Any(anyhow::anyhow!( + "cannot convert path to unicode string" + ))) + } + }; + let entries = smol::block_on(async { sftp.read_dir(¤t_path).await })?; + let entries = entries + .into_iter() + .map(|(path, meta)| Entry { path, meta }) + .collect(); + Ok(Self { + sftp, + current_path, + entries, + previous_path: vec![], + forward_path: vec![], + }) + } +} + +impl TerminalView<'_> { + pub fn show_sftp_window(&mut self, ctx: &Context) { + if let Some(explorer) = self.term_ctx.sftp_explorer { + Window::new("Sftp Window") + .open(self.term_ctx.show_sftp_window) + .max_width(1000.) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + TopBottomPanel::bottom("sftp_bottom_panel").show_inside(ui, |ui| { + ui.with_layout(Layout::right_to_left(egui::Align::TOP), |_ui| {}); + }); + + CentralPanel::default().show_inside(ui, |ui| { + egui::ScrollArea::both() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let text_size = + egui::TextStyle::Body.resolve(ui.style()).size + 10.0; + + TableBuilder::new(ui) + .column(egui_extras::Column::initial(300.0)) + .column(egui_extras::Column::initial(100.0)) + .column(egui_extras::Column::initial(100.0)) + .column(egui_extras::Column::initial(100.0)) + .column(egui_extras::Column::initial(100.0)) + .column(egui_extras::Column::remainder()) + .resizable(true) + .striped(true) + .header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Name"); + }); + header.col(|ui| { + ui.strong("Type"); + }); + header.col(|ui| { + ui.strong("Size"); + }); + header.col(|ui| { + ui.strong("Last accessed"); + }); + header.col(|ui| { + ui.strong("Last modified"); + }); + header.col(|ui| { + ui.strong("Permissions"); + }); + }) + .body(|body| { + body.rows(text_size, explorer.entries.len(), |mut row| { + let row_index = row.index(); + + if let Some(entry) = explorer.entries.get(row_index) { + let file_name = + entry.path.file_name().unwrap_or_default(); + let entry_type = match entry.meta.ty { + FileType::File => { + let mut file_type = "File".to_string(); + if let Ok(t) = + FileFormat::from_file(&entry.path) + { + if let Some(short_name) = t.short_name() + { + file_type = + format!("{} File", short_name); + } + } + file_type + } + FileType::Dir => "Folder".to_string(), + FileType::Symlink => "Symlink".to_string(), + FileType::Other => "Other".to_string(), + }; + + row.col(|ui| { + let _entry_label = { + ui.push_id(file_name, |ui| { + ui.with_layout( + Layout::left_to_right( + egui::Align::Min, + ), + |ui| { + if ui + .selectable_label( + false, file_name, + ) + .clicked() + { + } + }, + ) + }) + .inner + }; + }); + row.col(|ui| { + ui.with_layout( + Layout::left_to_right(egui::Align::Min), + |ui| { + ui.label(entry_type); + }, + ); + }); + + row.col(|ui| { + if let Some(size) = entry.meta.size { + ui.with_layout( + Layout::left_to_right(egui::Align::Min), + |ui| { + ui.label(bytesize::to_string( + size, false, + )); + }, + ); + } + }); + + row.col(|ui| { + if let Some(accessed) = entry.meta.accessed { + ui.with_layout( + Layout::left_to_right(egui::Align::Min), + |ui| { + ui.label(duration_to_string( + Duration::milliseconds( + accessed as i64, + ), + )); + }, + ); + } + }); + + row.col(|ui| { + if let Some(modified) = entry.meta.modified { + ui.with_layout( + Layout::left_to_right(egui::Align::Min), + |ui| { + ui.label(duration_to_string( + Duration::milliseconds( + modified as i64, + ), + )); + }, + ); + } + }); + + row.col(|ui| { + if let Some(permissions) = + entry.meta.permissions + { + ui.with_layout( + Layout::left_to_right(egui::Align::Min), + |ui| { + ui.label(to_rwx_string( + permissions, + )); + }, + ); + } + }); + } + }); + }); + }); + }); + }); + } + + if !*self.term_ctx.show_sftp_window { + *self.term_ctx.sftp_explorer = None; + } + } +} + +pub fn duration_to_string(duration: Duration) -> String { + if duration.whole_weeks() >= 1 { + format!("{} weeks ago", duration.whole_weeks()) + } else if duration.whole_days() >= 1 { + format!("{} days ago", duration.whole_days()) + } else if duration.whole_hours() >= 1 { + format!("{} hours ago", duration.whole_days()) + } else if duration.whole_minutes() >= 1 { + format!("{} minutes ago", duration.whole_minutes()) + } else { + format!("{} seconds ago", duration.whole_seconds()) + } +} + +pub fn to_rwx_string(permission: FilePermissions) -> String { + fn perms_to_str(read: bool, write: bool, exec: bool) -> String { + [ + if read { 'r' } else { '-' }, + if write { 'w' } else { '-' }, + if exec { 'x' } else { '-' }, + ] + .iter() + .collect() + } + format!( + "{}{}{}", + perms_to_str( + permission.owner_read, + permission.owner_write, + permission.owner_exec + ), + perms_to_str( + permission.group_read, + permission.group_write, + permission.group_exec + ), + perms_to_str( + permission.other_read, + permission.other_write, + permission.other_exec + ), + ) +} diff --git a/crates/egui-term/src/errors.rs b/crates/egui-term/src/errors.rs index ca41bf6..9ca1076 100644 --- a/crates/egui-term/src/errors.rs +++ b/crates/egui-term/src/errors.rs @@ -1,4 +1,5 @@ -use wezterm_ssh::HostVerificationFailed; +use homedir::GetHomeError; +use wezterm_ssh::{HostVerificationFailed, SftpChannelError}; #[derive(Debug, thiserror::Error)] pub enum TermError { @@ -10,4 +11,8 @@ pub enum TermError { HostVerification(HostVerificationFailed), #[error("{0}")] Io(#[from] std::io::Error), + #[error("{0}")] + GetHome(#[from] GetHomeError), + #[error("{0}")] + SftpChannel(#[from] SftpChannelError), } diff --git a/crates/egui-term/src/view.rs b/crates/egui-term/src/view.rs index 0c38928..5f57ae5 100644 --- a/crates/egui-term/src/view.rs +++ b/crates/egui-term/src/view.rs @@ -89,6 +89,8 @@ impl Widget for TerminalView<'_> { ui.close_menu(); } + self.show_sftp_window(ui.ctx()); + self.focus(&layout) .resize(&layout) .process_input(&mut state, &layout) diff --git a/nxshell/Cargo.toml b/nxshell/Cargo.toml index 24f29da..000c953 100644 --- a/nxshell/Cargo.toml +++ b/nxshell/Cargo.toml @@ -37,7 +37,6 @@ rusqlite = { workspace = true, features = ["bundled"] } thiserror.workspace = true tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } -uuid = { workspace = true, features = ["v4"] } [target.'cfg(windows)'.dependencies] windows = { workspace = true, features = ["Win32_System_Threading"] } diff --git a/nxshell/src/ui/tab_view/mod.rs b/nxshell/src/ui/tab_view/mod.rs index 6ca2715..84c2959 100644 --- a/nxshell/src/ui/tab_view/mod.rs +++ b/nxshell/src/ui/tab_view/mod.rs @@ -55,6 +55,7 @@ impl Tab { terminal, terminal_theme: TerminalTheme::default(), term_type: typ, + show_sftp_window: false, }), }) } @@ -108,7 +109,11 @@ impl egui_dock::TabViewer for TabViewer<'_> { fn ui(&mut self, ui: &mut Ui, tab: &mut Self::Tab) { match &mut tab.inner { TabInner::Term(tab) => { - let term_ctx = TerminalContext::new(&mut tab.terminal, self.clipboard); + let term_ctx = TerminalContext::new( + &mut tab.terminal, + self.clipboard, + &mut tab.show_sftp_window, + ); let term_opt = TerminalOptions { font: &mut self.options.term_font, multi_exec: &mut self.options.multi_exec, diff --git a/nxshell/src/ui/tab_view/terminal.rs b/nxshell/src/ui/tab_view/terminal.rs index b109601..d054100 100644 --- a/nxshell/src/ui/tab_view/terminal.rs +++ b/nxshell/src/ui/tab_view/terminal.rs @@ -5,4 +5,5 @@ pub struct TerminalTab { pub terminal_theme: TerminalTheme, pub terminal: Terminal, pub term_type: TermType, + pub show_sftp_window: bool, } From 5f087403a53ed013cbe3162df8acb42ae033baa6 Mon Sep 17 00:00:00 2001 From: iamazy Date: Wed, 26 Feb 2025 08:21:58 +0800 Subject: [PATCH 7/7] feat: support sftp --- crates/egui-term/src/display/mod.rs | 1 + crates/egui-term/src/view.rs | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/crates/egui-term/src/display/mod.rs b/crates/egui-term/src/display/mod.rs index 046c5d2..b0b6144 100644 --- a/crates/egui-term/src/display/mod.rs +++ b/crates/egui-term/src/display/mod.rs @@ -226,6 +226,7 @@ impl TerminalView<'_> { match self.term_ctx.open_sftp(session) { Ok(_) => { *self.term_ctx.show_sftp_window = true; + self.options.surrender_focus(); } Err(err) => error!("opening sftp error: {err}"), } diff --git a/crates/egui-term/src/view.rs b/crates/egui-term/src/view.rs index 5f57ae5..a8b1bb0 100644 --- a/crates/egui-term/src/view.rs +++ b/crates/egui-term/src/view.rs @@ -55,6 +55,14 @@ pub struct TerminalOptions<'a> { pub active_tab_id: Option<&'a mut Id>, } +impl TerminalOptions<'_> { + pub fn surrender_focus(&mut self) { + if let Some(active_tab_id) = self.active_tab_id.as_mut() { + **active_tab_id = Id::NULL; + } + } +} + impl Widget for TerminalView<'_> { fn ui(mut self, ui: &mut egui::Ui) -> Response { let (layout, painter) = ui.allocate_painter(self.size, egui::Sense::click());