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
This commit is contained in:
TomZz
2026-06-16 04:16:13 +08:00
parent 68bf152792
commit 162a245a8f
7 changed files with 144 additions and 42 deletions
+5 -2
View File
@@ -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()
+16 -10
View File
@@ -199,6 +199,7 @@ pub(crate) struct Ashell {
pub(crate) password_input: Entity<InputState>,
pub(crate) key_path_input: Entity<InputState>,
pub(crate) key_inline_input: Entity<InputState>,
pub(crate) passphrase_input: Entity<InputState>,
pub(crate) sftp_path_input: Entity<InputState>,
pub(crate) ssh_auth_method: AuthMethod,
pub(crate) editing_session_id: Option<String>,
@@ -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();
}
}
+4 -2
View File
@@ -352,6 +352,8 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
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<PrivateKey> {
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())),
}
+30 -2
View File
@@ -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<String>,
}
@@ -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::<ConfigFile>(&raw).unwrap_or_default()
match serde_json::from_str::<ConfigFile>(&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()
};
+34 -11
View File
@@ -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<Self>) {
@@ -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;
+18 -14
View File
@@ -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<PrivateKey> {
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<PrivateKey> {
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;
+37 -1
View File
@@ -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<TransferStateCompat> 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<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
TransferStateCompat::deserialize(deserializer).map(Into::into)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]