mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b45364331d | ||
|
|
2d92f0ceee | ||
|
|
162a245a8f | ||
|
|
68bf152792 | ||
|
|
13b9433c90 |
Generated
+1
-1
@@ -303,7 +303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.4.0"
|
||||
version = "0.4.2"
|
||||
edition = "2024"
|
||||
authors = ["ashell contributors"]
|
||||
description = "A GPUI Component based SSH and local terminal client"
|
||||
|
||||
+9
-8
@@ -8,15 +8,15 @@
|
||||
|
||||
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
|
||||
|
||||
## 🚀 v0.3 Massive Update!
|
||||
## 🚀 v0.4 Major Upgrades
|
||||
|
||||
The brand-new v0.3 introduces comprehensive upgrades to both architecture and user experience:
|
||||
- ✨ **Global Real-Time Font Settings** (Thanks to [@ancion](https://github.com/ancion) for [PR #6](https://github.com/rust-kotlin/ashell/pull/6)): Customize your global UI fonts and terminal rendering fonts independently. Adjust text size in real-time with automatic persistence.
|
||||
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress display.
|
||||
- ✨ **Persistent Workspace State**: Your sidebar width adjustments, terminal themes, font settings, and file visibility preferences are completely restored upon your next launch!
|
||||
- ✨ **Intelligent SSH Disconnect/Reconnect**: Greatly enhanced SSH connection resilience. Provides a beautiful and localized overlay for one-click re-connection on unexpected disconnections.
|
||||
- ✨ **Hot-Swappable i18n System**: Deeply optimized internationalization. Switch instantly between English and Simplified Chinese without restarting the application.
|
||||
- ✨ **Smart Right-Click Actions**: Added intelligent right-click copy and paste for the terminal, featuring a dedicated settings toggle and OS-native keyboard shortcut hints.
|
||||
v0.4 builds on the v0.3 foundation and focuses on more capable workspace operations and a smoother daily workflow:
|
||||
- ✨ **Keybinding Management**: View and edit common shortcuts from the settings UI with conflict hints.
|
||||
- ✨ **Settings Page Polish**: A more compact and clearer settings experience with improved layout and interaction flow.
|
||||
- ✨ **Multi-Pane Tabs with a tmux-like Workflow**: A single tab can now host multiple panes, with split, focus, and switching actions for a tmux-inspired experience.
|
||||
- ✨ **Transfer History Improvements**: The transfer history panel now presents richer task details, making upload and download activity easier to review.
|
||||
- ✨ **SSH Passphrase Support**: Private keys can now store a passphrase, and SSH connections will use it automatically.
|
||||
- ✨ **Terminal Rendering Enhancements**: Terminal rendering now handles Block Elements and similar custom glyphs more completely.
|
||||
|
||||
## Download
|
||||
|
||||
@@ -43,6 +43,7 @@ The current version provides a fully-featured GPUI-native workspace:
|
||||
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
|
||||
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
|
||||
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
|
||||
- **v0.3 Core Enhancements:** Global font and font-size controls, concurrent SFTP transfers, persistent layout state, disconnect awareness, hot-swappable i18n, and smart terminal right-click copy/paste.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
||||
|
||||
## 🚀 v0.3 版本重磅更新!
|
||||
## 🚀 v0.4 版本重要升级
|
||||
|
||||
全新发布的 v0.3 迎来了架构和体验的全面升级:
|
||||
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
|
||||
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
|
||||
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
|
||||
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
|
||||
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
|
||||
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
|
||||
v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能力和更顺手的日常体验:
|
||||
- ✨ **键位管理功能**:支持在设置中可视化查看和修改常用快捷键,并提供冲突提示。
|
||||
- ✨ **设置页优化**:设置页的布局和交互更加紧凑顺手,整体信息层级更清晰。
|
||||
- ✨ **Tab 内多 Pane 与类 tmux 体验**:一个 tab 内现在可以管理多个 pane,支持分屏、聚焦和切换,提供更接近 tmux 的工作流。
|
||||
- ✨ **传输历史增强**:传输历史面板展示更完整的任务信息,便于回溯上传和下载过程。
|
||||
- ✨ **SSH passphrase 支持**:现在可以为私钥填写 passphrase,并在连接时自动使用保存的口令。
|
||||
- ✨ **终端显示优化**:终端渲染进一步增强,支持 Block Elements 等自定义图形字符的更完整显示。
|
||||
|
||||
## 下载
|
||||
|
||||
@@ -43,6 +43,7 @@ sudo xattr -cr /Applications/ashell.app
|
||||
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
|
||||
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
|
||||
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
|
||||
- **v0.3 核心增强**:全局字体与字号可调,支持 SFTP 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
+13
-5
@@ -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()
|
||||
@@ -517,8 +520,12 @@ impl Ashell {
|
||||
)
|
||||
} else {
|
||||
match t.info.kind {
|
||||
crate::terminal::TransferType::Upload => format!("{}...", t!("uploading")),
|
||||
crate::terminal::TransferType::Download => format!("{}...", t!("downloading")),
|
||||
crate::terminal::TransferType::Upload => {
|
||||
format!("{}...", t!("uploading"))
|
||||
}
|
||||
crate::terminal::TransferType::Download => {
|
||||
format!("{}...", t!("downloading"))
|
||||
}
|
||||
}
|
||||
};
|
||||
let btn_pause =
|
||||
@@ -753,7 +760,6 @@ impl Ashell {
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
}));
|
||||
|
||||
let scroll_handle = window
|
||||
@@ -1030,6 +1036,8 @@ impl Ashell {
|
||||
|
||||
content.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.track_focus(&focus_handle)
|
||||
.on_key_down({
|
||||
|
||||
+18
-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>,
|
||||
@@ -237,6 +238,7 @@ pub(crate) struct Ashell {
|
||||
pub(crate) system_status: Option<SharedString>,
|
||||
pub(crate) pane_root: PaneLayout,
|
||||
pub(crate) focused_pane_path: Vec<usize>,
|
||||
pub(crate) terminal_panel_bounds: Option<Bounds<Pixels>>,
|
||||
pub(crate) terminal_bounds: HashMap<String, Bounds<Pixels>>,
|
||||
pub(crate) terminal_selecting: bool,
|
||||
pub(crate) dragging_splitter: Option<(Vec<usize>, usize)>, // (parent_path, child_index)
|
||||
@@ -331,6 +333,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 +350,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 +366,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 +408,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,
|
||||
@@ -422,6 +426,7 @@ impl Ashell {
|
||||
active_group: None,
|
||||
pane_root: PaneLayout::Single(String::new()),
|
||||
focused_pane_path: Vec::new(),
|
||||
terminal_panel_bounds: None,
|
||||
selector_selection: 0,
|
||||
workspace_panels,
|
||||
body_panels,
|
||||
@@ -443,8 +448,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 +878,4 @@ impl Ashell {
|
||||
self.config.set_transfers(self.transfers.clone());
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1692,10 +1692,16 @@ impl Ashell {
|
||||
fn render_terminal_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let has_active = self.active_tab.is_some();
|
||||
let pane_tree = self.pane_root.clone();
|
||||
let view = cx.entity();
|
||||
|
||||
v_flex().size_full().child(
|
||||
div()
|
||||
.size_full()
|
||||
.on_prepaint(move |bounds, _window, cx| {
|
||||
let _ = view.update(cx, |this, _| {
|
||||
this.terminal_panel_bounds = Some(bounds);
|
||||
});
|
||||
})
|
||||
.overflow_hidden()
|
||||
.track_focus(&self.focus_handle)
|
||||
.key_context(TERMINAL_KEY_CONTEXT)
|
||||
|
||||
+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()
|
||||
};
|
||||
|
||||
+29
-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,26 @@ 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());
|
||||
|
||||
// Use the whole terminal panel bounds for PTY sizing.
|
||||
// Individual pane bounds are smaller when split, so using them here
|
||||
// would shrink the root PTY dimensions a second time.
|
||||
let (width, height) = if let Some(bounds) = self.terminal_panel_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