From 162a245a8f64948ad0784aaa5f02259b4ab4f1f6 Mon Sep 17 00:00:00 2001 From: TomZz Date: Tue, 16 Jun 2026 04:16:13 +0800 Subject: [PATCH] feat: improve theme defaults, config compatibility, and SSH key passphrase support - default follow_system_theme to true and remove empty theme-name fallback override\n- add backward compatibility for v0.3.11 transfer state values during config load\n- preserve old config files by backing up parse failures instead of silently overwriting them\n- add optional SSH key passphrase input to the new SSH form and persist it in sessions\n- use stored passphrases when loading private keys for SSH and SFTP connections\n- make terminal size sync prefer measured bounds to reduce unused space at the bottom --- src/app/dialogs.rs | 7 +++++-- src/app/mod.rs | 26 +++++++++++++++---------- src/backend/ssh.rs | 6 ++++-- src/session/config.rs | 32 ++++++++++++++++++++++++++++-- src/session/mod.rs | 45 ++++++++++++++++++++++++++++++++----------- src/sftp/mod.rs | 32 ++++++++++++++++-------------- src/terminal/mod.rs | 38 +++++++++++++++++++++++++++++++++++- 7 files changed, 144 insertions(+), 42 deletions(-) diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 1fa5a8c..3c6250a 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -30,6 +30,7 @@ impl Ashell { let password_input = self.password_input.clone(); let key_path_input = self.key_path_input.clone(); let key_inline_input = self.key_inline_input.clone(); + let passphrase_input = self.passphrase_input.clone(); window.open_dialog(cx, move |dialog: Dialog, _window, _cx| { dialog @@ -45,6 +46,7 @@ impl Ashell { let password_input = password_input.clone(); let key_path_input = key_path_input.clone(); let key_inline_input = key_inline_input.clone(); + let passphrase_input = passphrase_input.clone(); move |content, window, cx| { let is_password = view.read(cx).ssh_auth_method == AuthMethod::Password; let is_editing = view.read(cx).editing_session_id.is_some(); @@ -114,7 +116,7 @@ impl Ashell { ), ) .child( - Input::new(&key_path_input).tab_index(5), + Input::new(&key_path_input).tab_index(4), ), ) .child( @@ -134,7 +136,8 @@ impl Ashell { )), ), ) - .child(Input::new(&key_inline_input).h(px(128.)).tab_index(6)) + .child(Input::new(&key_inline_input).h(px(128.)).tab_index(5)) + .child(Input::new(&passphrase_input).mask_toggle().tab_index(6)) }) .child( h_flex() diff --git a/src/app/mod.rs b/src/app/mod.rs index c047ac8..66086ea 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -199,6 +199,7 @@ pub(crate) struct Ashell { pub(crate) password_input: Entity, pub(crate) key_path_input: Entity, pub(crate) key_inline_input: Entity, + pub(crate) passphrase_input: Entity, pub(crate) sftp_path_input: Entity, pub(crate) ssh_auth_method: AuthMethod, pub(crate) editing_session_id: Option, @@ -331,6 +332,11 @@ impl Ashell { .rows(5) .placeholder("-----BEGIN OPENSSH PRIVATE KEY-----") }); + let passphrase_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("SSH private key passphrase (optional)") + .masked(true) + }); let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/")); let sftp_new_folder_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string())); @@ -343,6 +349,7 @@ impl Ashell { cx.subscribe_in(&password_input, window, Self::on_input_event), cx.subscribe_in(&key_path_input, window, Self::on_input_event), cx.subscribe_in(&key_inline_input, window, Self::on_input_event), + cx.subscribe_in(&passphrase_input, window, Self::on_input_event), cx.subscribe_in(&sftp_path_input, window, Self::on_input_event), cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event), ]; @@ -358,12 +365,7 @@ impl Ashell { tracing::warn!("failed to load config: {err:#}"); ConfigStore::in_memory() }); - let follow_system_theme = - if config.light_theme_name().is_empty() && config.dark_theme_name().is_empty() { - true - } else { - config.follow_system_theme() - }; + let follow_system_theme = config.follow_system_theme(); let theme_mode = match config.theme_mode() { "light" => ThemeMode::Light, @@ -405,6 +407,7 @@ impl Ashell { password_input, key_path_input, key_inline_input, + passphrase_input, sftp_path_input, ssh_auth_method: AuthMethod::Password, editing_session_id: None, @@ -443,8 +446,13 @@ impl Ashell { transfers: { let mut transfers = config.transfers(); for t in transfers.iter_mut() { - if matches!(t.state, crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused) { - t.state = crate::terminal::TransferState::Zombie(t!("zombie_reason").to_string()); + if matches!( + t.state, + crate::terminal::TransferState::Running + | crate::terminal::TransferState::Paused + ) { + t.state = + crate::terminal::TransferState::Zombie(t!("zombie_reason").to_string()); } } transfers @@ -868,6 +876,4 @@ impl Ashell { self.config.set_transfers(self.transfers.clone()); cx.notify(); } - - } diff --git a/src/backend/ssh.rs b/src/backend/ssh.rs index 8b4cb4d..f94fe38 100644 --- a/src/backend/ssh.rs +++ b/src/backend/ssh.rs @@ -352,6 +352,8 @@ async fn connect_and_authenticate( fn load_session_private_key(session: &Session) -> Result { let inline_key = normalize_inline_private_key(&session.private_key_inline); let key_path = expand_key_path(session.private_key_path.trim()); + let passphrase = session.passphrase.trim(); + let passphrase = (!passphrase.is_empty()).then_some(passphrase); let has_inline = !inline_key.is_empty(); let has_path = key_path.is_some(); @@ -362,14 +364,14 @@ fn load_session_private_key(session: &Session) -> Result { let mut errors = Vec::new(); if has_inline { - match decode_secret_key(&inline_key, None) { + match decode_secret_key(&inline_key, passphrase) { Ok(key) => return Ok(key), Err(err) => errors.push(format!("decode private key content: {err}")), } } if let Some(path) = key_path { - match load_secret_key(path.as_path(), None) { + match load_secret_key(path.as_path(), passphrase) { Ok(key) => return Ok(key), Err(err) => errors.push(format!("load key {}: {err}", path.display())), } diff --git a/src/session/config.rs b/src/session/config.rs index 59b8eeb..1f1f18e 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -27,6 +27,8 @@ pub struct Session { #[serde(default)] pub private_key_inline: String, #[serde(default)] + pub passphrase: String, + #[serde(default)] pub last_used: Option, } @@ -43,6 +45,7 @@ impl Session { password, private_key_path: String::new(), private_key_inline: String::new(), + passphrase: String::new(), last_used: None, } } @@ -53,6 +56,7 @@ impl Session { user: String, private_key_path: String, private_key_inline: String, + passphrase: String, ) -> Self { let name = format!("{user}@{host}"); Self { @@ -65,6 +69,7 @@ impl Session { password: String::new(), private_key_path, private_key_inline, + passphrase, last_used: None, } } @@ -95,7 +100,7 @@ pub enum SavedWindowBounds { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ConfigFile { - #[serde(default)] + #[serde(default = "default_follow_system_theme")] pub follow_system_theme: bool, #[serde(default)] pub theme_mode: String, @@ -137,6 +142,10 @@ fn default_monitoring_position() -> String { "Sidebar".to_string() } +fn default_follow_system_theme() -> bool { + true +} + fn default_locale() -> String { "system".to_string() } @@ -188,7 +197,26 @@ impl ConfigStore { let cache = if path.exists() { let raw = fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; - serde_json::from_str::(&raw).unwrap_or_default() + match serde_json::from_str::(&raw) { + Ok(cache) => cache, + Err(err) => { + let backup_path = path.with_extension("json.bak"); + if let Err(backup_err) = fs::write(&backup_path, raw.as_bytes()) { + tracing::warn!( + "failed to parse config {}; backup to {} also failed: {backup_err:#}; parse error: {err:#}", + path.display(), + backup_path.display(), + ); + } else { + tracing::warn!( + "failed to parse config {}; backed up the original to {} and loaded defaults: {err:#}", + path.display(), + backup_path.display(), + ); + } + ConfigFile::default() + } + } } else { ConfigFile::default() }; diff --git a/src/session/mod.rs b/src/session/mod.rs index f0a9b3f..4948d43 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -71,6 +71,7 @@ impl Ashell { let password = self.password_input.read(cx).value().to_string(); let key_path = self.key_path_input.read(cx).value().trim().to_string(); let key_inline = self.key_inline_input.read(cx).value().to_string(); + let passphrase = self.passphrase_input.read(cx).value().to_string(); if host.is_empty() || user.is_empty() { self.status = t!("host_and_user_required").into(); @@ -97,7 +98,7 @@ impl Ashell { cx.notify(); return; } - Session::key(host, port, user, key_path, key_inline) + Session::key(host, port, user, key_path, key_inline, passphrase) } }; session.name = name; @@ -134,6 +135,7 @@ impl Ashell { Self::set_input_value(&self.password_input, "", window, cx); Self::set_input_value(&self.key_path_input, "", window, cx); Self::set_input_value(&self.key_inline_input, "", window, cx); + Self::set_input_value(&self.passphrase_input, "", window, cx); } pub(crate) fn load_session_into_form( @@ -161,6 +163,12 @@ impl Ashell { window, cx, ); + Self::set_input_value( + &self.passphrase_input, + session.passphrase.clone(), + window, + cx, + ); } pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context) { @@ -867,16 +875,31 @@ impl Ashell { .first() .map(|size| size.as_f32()) .unwrap_or(SIDEBAR_WIDTH); - let terminal_height = self - .body_panels - .read(cx) - .sizes() - .first() - .map(|size| size.as_f32()) - .unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0); - let width = (viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0) - .max(self.terminal_cell_width()); - let height = (terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height()); + let terminal_bounds = self + .active_tab + .as_ref() + .and_then(|tab_id| self.terminal_bounds.get(tab_id)); + + // Prefer the measured terminal element bounds when we already have + // them. That keeps the PTY sized to the real visible content area, + // instead of relying on a conservative viewport heuristic that can + // leave several rows unused at the bottom. + let (width, height) = if let Some(bounds) = terminal_bounds { + (bounds.size.width.as_f32(), bounds.size.height.as_f32()) + } else { + let terminal_height = self + .body_panels + .read(cx) + .sizes() + .first() + .map(|size| size.as_f32()) + .unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0); + ( + (viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0) + .max(self.terminal_cell_width()), + (terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height()), + ) + }; let total_cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16; let total_rows = (height / self.terminal_line_height()).floor().max(1.0) as u16; diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index cb72d5c..a331bf5 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -381,16 +381,18 @@ async fn run_sftp( let err_msg = format!("{err:#}"); let is_cancelled = err_msg.contains("transfer cancelled"); let state = if is_cancelled { - crate::terminal::TransferState::Interrupted("User cancelled".to_string()) + crate::terminal::TransferState::Interrupted( + "User cancelled".to_string(), + ) } else { crate::terminal::TransferState::Failed(err_msg.clone()) }; let _ = events_clone.send(BackendEvent::SftpStatus { tab_id: tab_id_clone.clone(), - text: if is_cancelled { + text: if is_cancelled { "Transmission cancelled".to_string() - } else { - t!("download_failed", err = err_msg.clone()).to_string() + } else { + t!("download_failed", err = err_msg.clone()).to_string() }, }); let _ = events_clone.send(BackendEvent::TransferProgress { @@ -492,16 +494,18 @@ async fn run_sftp( let err_msg = format!("{err:#}"); let is_cancelled = err_msg.contains("transfer cancelled"); let state = if is_cancelled { - crate::terminal::TransferState::Interrupted("User cancelled".to_string()) + crate::terminal::TransferState::Interrupted( + "User cancelled".to_string(), + ) } else { crate::terminal::TransferState::Failed(err_msg.clone()) }; let _ = events_clone.send(BackendEvent::SftpStatus { tab_id: tab_id_clone.clone(), - text: if is_cancelled { + text: if is_cancelled { "Transmission cancelled".to_string() - } else { - t!("upload_failed", err = err_msg.clone()).to_string() + } else { + t!("upload_failed", err = err_msg.clone()).to_string() }, }); let _ = events_clone.send(BackendEvent::TransferProgress { @@ -888,6 +892,8 @@ async fn connect_and_authenticate( fn load_session_private_key(session: &Session) -> Result { let inline_key = normalize_inline_private_key(&session.private_key_inline); let key_path = expand_key_path(session.private_key_path.trim()); + let passphrase = session.passphrase.trim(); + let passphrase = (!passphrase.is_empty()).then_some(passphrase); let has_inline = !inline_key.is_empty(); let has_path = key_path.is_some(); @@ -898,14 +904,14 @@ fn load_session_private_key(session: &Session) -> Result { let mut errors = Vec::new(); if has_inline { - match decode_secret_key(&inline_key, None) { + match decode_secret_key(&inline_key, passphrase) { Ok(key) => return Ok(key), Err(err) => errors.push(format!("decode private key content: {err}")), } } if let Some(path) = key_path { - match load_secret_key(path.as_path(), None) { + match load_secret_key(path.as_path(), passphrase) { Ok(key) => return Ok(key), Err(err) => errors.push(format!("load key {}: {err}", path.display())), } @@ -1594,14 +1600,14 @@ async fn exec_remote_command( let mut stderr = Vec::new(); let mut stdout = Vec::new(); let mut exit_status = None; - + // Add timeout to prevent indefinite blocking (300 seconds = 5 minutes) let timeout = tokio::time::Duration::from_secs(300); let result = tokio::time::timeout(timeout, async { loop { // Yield to allow cancellation tokio::task::yield_now().await; - + if let Some(msg) = channel.wait().await { match msg { russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data), @@ -1782,8 +1788,6 @@ async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> { Ok(()) } - - #[derive(Clone)] struct SftpClientHandler; diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index abdcf65..a03edc5 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -678,7 +678,7 @@ pub enum TransferType { Download, } -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub enum TransferState { Running, Paused, @@ -686,6 +686,42 @@ pub enum TransferState { Failed(String), Interrupted(String), // 中断传输:包含原因(例如 "User cancelled", "Network timeout") Zombie(String), // 程序重启后残留的 Running/Paused 任务 + // 兼容 v0.3.11 -> v0.4.x:旧配置里曾保存过 `Cancelled`, + // 新版本改成了带原因的状态,因此要手动接住旧枚举值。 +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +enum TransferStateCompat { + Running, + Paused, + Completed, + Failed(String), + Interrupted(String), + Zombie(String), + Cancelled, +} + +impl From for TransferState { + fn from(value: TransferStateCompat) -> Self { + match value { + TransferStateCompat::Running => Self::Running, + TransferStateCompat::Paused => Self::Paused, + TransferStateCompat::Completed => Self::Completed, + TransferStateCompat::Failed(reason) => Self::Failed(reason), + TransferStateCompat::Interrupted(reason) => Self::Interrupted(reason), + TransferStateCompat::Zombie(reason) => Self::Zombie(reason), + TransferStateCompat::Cancelled => Self::Interrupted("Cancelled".to_string()), + } + } +} + +impl<'de> serde::Deserialize<'de> for TransferState { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + TransferStateCompat::deserialize(deserializer).map(Into::into) + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]