mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-09-22 00:00:59 +00:00
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:
+5
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user