Compare commits

..
6 Commits
Author SHA1 Message Date
TomZz f1186e4c84 fix: UI/UX improvements for tabs, sidebar layout, and scrolling
- Redesign TabBar: remove bottom border and adjust height to a compact 40px.
- Fix UI auto-scrolling: TabBar, Open Session dialog, and Saved Sessions sidebar now accurately scroll selected items into view.
- Fix layout bounds for scrollbars so they render continuously and always stay visible.
- Pin sidebar headers (logo, stats) to the top while only the session list scrolls.
2026-06-12 05:32:54 +08:00
TomZz 2595cc4853 fix: prevent IME composition interrupt by only syncing theme when system appearance changes 2026-06-12 04:50:54 +08:00
TomZz 2ae28df8f8 Fix: Prevent auto-dismiss of newly opened dialogs when using keyboard navigation in Open Session selector 2026-06-12 03:38:41 +08:00
TomZz 58fd03386a Feat: Implement intelligent right-click copy/paste and add missing translations 2026-06-12 02:27:09 +08:00
TomZz 890ce85cfc Docs: Update preview image 2026-06-12 01:42:20 +08:00
TomZz d5fb956161 Docs: Update README with v0.3 release highlights 2026-06-12 01:31:07 +08:00
7 changed files with 292 additions and 108 deletions
+10
View File
@@ -8,6 +8,16 @@
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!
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.
## Download
You can download the latest pre-compiled releases for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
+10
View File
@@ -8,6 +8,16 @@
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
## 🚀 v0.3 版本重磅更新!
全新发布的 v0.3 迎来了架构和体验的全面升级:
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
## 下载
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
+4 -1
View File
@@ -91,4 +91,7 @@ failed: "Failed"
cancelled: "Cancelled"
session: "Session"
software_builtin: "Built-in"
software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to copy/paste."
+4 -1
View File
@@ -91,4 +91,7 @@ failed: "失败"
cancelled: "已取消"
session: "会话"
software_builtin: "软件内置"
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 279 KiB

+10
View File
@@ -109,6 +109,8 @@ pub struct ConfigFile {
pub terminal_font_size: f32,
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
#[serde(default)]
pub right_click_copy_paste: bool,
#[serde(default = "default_ui_font_family")]
pub ui_font_family: String,
#[serde(default = "default_terminal_font_family")]
@@ -302,6 +304,14 @@ impl ConfigStore {
self.cache.ui_font_family = family.to_string();
}
pub fn right_click_copy_paste(&self) -> bool {
self.cache.right_click_copy_paste
}
pub fn set_right_click_copy_paste(&mut self, val: bool) {
self.cache.right_click_copy_paste = val;
}
pub fn terminal_font_family(&self) -> &str {
if self.cache.terminal_font_family.is_empty() {
"Maple Mono NF CN"
+254 -106
View File
@@ -26,6 +26,7 @@ use gpui_component::{
ThemeRegistry, WindowExt as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
switch::Switch,
dialog::Dialog,
h_flex,
input::{Input, InputEvent, InputState},
@@ -164,6 +165,9 @@ struct Ashell {
body_panels: Entity<ResizableState>,
terminal_scrollbar: TerminalScrollbarHandle,
remote_files_scroll_handle: UniformListScrollHandle,
tabs_scroll_handle: gpui::ScrollHandle,
selector_scroll_handle: gpui::ScrollHandle,
saved_scroll_handle: gpui::ScrollHandle,
connection_progress: Option<ConnectionProgress>,
pending_sftp_path_sync: Option<String>,
sftp_context_menu: Option<SftpContextMenuState>,
@@ -180,6 +184,7 @@ struct Ashell {
system: SystemSnapshot,
last_system_sample: Instant,
last_theme_sync: Instant,
last_window_appearance: Option<gpui::WindowAppearance>,
remote_sample_in_flight: bool,
runtime: Runtime,
events_rx: mpsc::Receiver<BackendEvent>,
@@ -319,6 +324,9 @@ impl Ashell {
body_panels,
terminal_scrollbar: TerminalScrollbarHandle::default(),
remote_files_scroll_handle: UniformListScrollHandle::new(),
tabs_scroll_handle: gpui::ScrollHandle::new(),
selector_scroll_handle: gpui::ScrollHandle::new(),
saved_scroll_handle: gpui::ScrollHandle::new(),
connection_progress: None,
pending_sftp_path_sync: Some("/".into()),
sftp_context_menu: None,
@@ -335,6 +343,7 @@ impl Ashell {
system,
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
last_window_appearance: None,
remote_sample_in_flight: false,
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
@@ -578,7 +587,11 @@ impl Ashell {
fn sync_theme_if_due(&mut self, cx: &mut Context<Self>) {
if self.follow_system_theme && self.last_theme_sync.elapsed() >= Duration::from_secs(1) {
Theme::sync_system_appearance(None, cx);
let appearance = cx.window_appearance();
if self.last_window_appearance != Some(appearance) {
Theme::sync_system_appearance(None, cx);
self.last_window_appearance = Some(appearance);
}
self.last_theme_sync = Instant::now();
}
}
@@ -626,6 +639,7 @@ impl Ashell {
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
self.tabs.push(tab);
self.active_tab = Some(id);
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
self.status = "local terminal opened".into();
}
Err(err) => {
@@ -939,7 +953,7 @@ impl Ashell {
self.selector_selection = self.default_selector_index();
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
dialog
.title("Open Session")
.title(t!("open_session").to_string())
.w(px(520.))
.on_ok({
let view = view.clone();
@@ -947,7 +961,7 @@ impl Ashell {
view.update(cx, |this, cx| {
this.activate_selector_selection(window, cx);
});
true
false
}
})
.content({
@@ -957,6 +971,7 @@ impl Ashell {
let selector_focus_handle = selector_focus_handle.clone();
move |content, window, _cx| {
let selected_index = view.read(_cx).selector_selection;
let scroll_handle = view.read(_cx).selector_scroll_handle.clone();
content.child(
v_flex()
.track_focus(&selector_focus_handle)
@@ -1052,19 +1067,26 @@ impl Ashell {
),
)
.child(
v_flex()
div()
.relative()
.max_h(px(320.))
.overflow_scrollbar()
.gap_2()
.children(sessions.clone().into_iter().enumerate().map(
|(ix, session)| {
let connect_id = session.id.clone();
let is_selected = selected_index == ix + 2;
let name = session.name.clone();
let detail = format!(
"{}@{}:{}",
session.user, session.host, session.port
);
.size_full()
.child(
v_flex()
.size_full()
.id("selector-scroll-view")
.track_scroll(&scroll_handle)
.overflow_y_scroll()
.gap_2()
.children(sessions.clone().into_iter().enumerate().map(
|(ix, session)| {
let connect_id = session.id.clone();
let is_selected = selected_index == ix + 2;
let name = session.name.clone();
let detail = format!(
"{}@{}:{}",
session.user, session.host, session.port
);
div()
.id(("selector-open", ix))
.w_full()
@@ -1119,7 +1141,22 @@ impl Ashell {
)
},
)),
),
)
.child(
div()
.absolute()
.top_0()
.bottom_0()
.left_0()
.right_0()
.child(
gpui_component::scroll::Scrollbar::new(&scroll_handle)
.id("selector-scrollbar")
.axis(gpui_component::scroll::ScrollbarAxis::Vertical)
.scrollbar_show(gpui_component::scroll::ScrollbarShow::Always)
)
)
)
)
}
})
@@ -1616,6 +1653,34 @@ impl Ashell {
),
),
)
.child(
v_flex()
.gap_1()
.child(
h_flex()
.items_center()
.gap_3()
.child(div().w(px(240.)).child(t!("right_click_copy_paste").to_string()))
.child(
Switch::new("right-click-copy-paste")
.checked(view.read(cx).config.right_click_copy_paste())
.on_click(window.listener_for(
&view,
|this, checked, _, cx| {
this.config.set_right_click_copy_paste(*checked);
let _ = this.config.save();
cx.notify();
},
)),
)
)
.child(
div()
.text_size(rems(0.85))
.text_color(cx.theme().muted_foreground)
.child(t!("copy_paste_hint", key = if cfg!(target_os = "macos") { "Command" } else { "Ctrl" }).to_string())
)
)
.child(
h_flex()
.items_center()
@@ -1777,6 +1842,9 @@ impl Ashell {
let next = (current + delta).clamp(0, entries.len() as i32 - 1) as usize;
if next != self.selector_selection {
self.selector_selection = next;
if next >= 2 {
self.selector_scroll_handle.scroll_to_item(next - 2);
}
cx.notify();
}
}
@@ -1846,6 +1914,14 @@ impl Ashell {
backend,
self.events_tx.clone(),
));
self.active_tab = Some(id.clone());
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
self.saved_scroll_handle.scroll_to_item(index);
}
}
cx.notify();
let sftp_handle = sftp::spawn_sftp(
self.runtime.handle(),
id.clone(),
@@ -1923,7 +1999,17 @@ impl Ashell {
{
self.pending_sftp_path_sync = Some(path);
}
self.active_tab = Some(id);
self.active_tab = Some(id.clone());
if let Some(index) = self.tabs.iter().position(|t| t.id == id) {
self.tabs_scroll_handle.scroll_to_item(index);
}
if self.tabs.iter().any(|t| t.id == id) {
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
self.saved_scroll_handle.scroll_to_item(index);
}
}
}
self.remote_sample_in_flight = false;
self.request_active_system_snapshot();
self.focus_handle.focus(window, cx);
@@ -2499,6 +2585,43 @@ impl Ashell {
cx.notify();
}
fn on_terminal_right_click(
&mut self,
_event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.config.right_click_copy_paste() {
return;
}
let mut handled = false;
if let Some(text) = self.active_terminal_selection_text() {
if !text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
let active_id = self.active_tab.clone();
if let Some(active_id) = active_id {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
tab.clear_selection();
}
}
cx.notify();
handled = true;
}
}
if !handled {
if let Some(clipboard_item) = cx.read_from_clipboard() {
if let Some(text) = clipboard_item.text() {
if !text.is_empty() {
self.paste_into_terminal(&text, window, cx);
}
}
}
}
}
fn begin_terminal_selection(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
let click_count = event.click_count.max(1);
let selection_type = match click_count {
@@ -3525,7 +3648,7 @@ impl Ashell {
.border_r_1()
.border_color(cx.theme().sidebar_border)
.bg(cx.theme().sidebar)
.overflow_y_scrollbar()
.overflow_hidden()
.child(
h_flex()
.items_center()
@@ -3649,6 +3772,8 @@ impl Ashell {
)
.child(
v_flex()
.flex_1()
.min_h(px(0.))
.gap_2()
.child(
div()
@@ -3658,91 +3783,114 @@ impl Ashell {
.child(t!("saved")),
)
.child(
v_flex()
.max_h(px(220.))
.overflow_scrollbar()
.gap_2()
.children(sessions.into_iter().enumerate().map(|(ix, session)| {
let connect_id = session.id.clone();
let edit_id = session.id.clone();
let delete_id = session.id.clone();
let is_active =
active_session_id.as_deref() == Some(session.id.as_str());
let name = session.name.clone();
let detail = self.session_detail(&session);
div()
.id(("saved-connect", ix))
.w_full()
.p_2()
.rounded_md()
.border_1()
.border_color(if is_active {
cx.theme().primary
} else {
cx.theme().border
})
.bg(if is_active {
cx.theme().tab_active
} else {
cx.theme().muted
})
.cursor_pointer()
.hover(|this| this.bg(cx.theme().secondary))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.connect_saved_session(connect_id.clone(), cx)
}),
)
.context_menu({
let view = cx.entity();
move |menu, window, _| {
let edit_value = edit_id.clone();
let delete_value = delete_id.clone();
menu.item(PopupMenuItem::new("Edit").on_click(
window.listener_for(
&view,
move |this, _, window, cx| {
this.edit_saved_session(
edit_value.clone(),
window,
cx,
)
},
),
))
.item(
PopupMenuItem::new("Delete").on_click(
window.listener_for(
&view,
move |this, _, _, cx| {
this.remove_saved_session(
delete_value.clone(),
cx,
)
},
div()
.relative()
.flex_1()
.min_h(px(0.))
.size_full()
.child(
v_flex()
.size_full()
.id("saved-sessions-scroll")
.track_scroll(&self.saved_scroll_handle)
.overflow_y_scroll()
.gap_2()
.children(sessions.into_iter().enumerate().map(|(ix, session)| {
let connect_id = session.id.clone();
let edit_id = session.id.clone();
let delete_id = session.id.clone();
let is_active =
active_session_id.as_deref() == Some(session.id.as_str());
let name = session.name.clone();
let detail = self.session_detail(&session);
div()
.id(("saved-connect", ix))
.w_full()
.p_2()
.rounded_md()
.border_1()
.border_color(if is_active {
cx.theme().primary
} else {
cx.theme().border
})
.bg(if is_active {
cx.theme().tab_active
} else {
cx.theme().muted
})
.cursor_pointer()
.hover(|this| this.bg(cx.theme().secondary))
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.connect_saved_session(connect_id.clone(), cx)
}),
)
.context_menu({
let view = cx.entity();
move |menu, window, _| {
let edit_value = edit_id.clone();
let delete_value = delete_id.clone();
menu.item(PopupMenuItem::new("Edit").on_click(
window.listener_for(
&view,
move |this, _, window, cx| {
this.edit_saved_session(
edit_value.clone(),
window,
cx,
)
},
),
))
.item(
PopupMenuItem::new("Delete").on_click(
window.listener_for(
&view,
move |this, _, _, cx| {
this.remove_saved_session(
delete_value.clone(),
cx,
)
},
),
),
)
}
})
.child(
v_flex()
.gap_1()
.child(
div()
.text_size(rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.child(name),
)
.child(
div()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(detail),
),
),
)
}
})
})),
)
.child(
div()
.absolute()
.top_0()
.bottom_0()
.left_0()
.right_0()
.child(
v_flex()
.gap_1()
.child(
div()
.text_size(rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.child(name),
)
.child(
div()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(detail),
),
gpui_component::scroll::Scrollbar::new(&self.saved_scroll_handle)
.id("saved-scrollbar")
.axis(gpui_component::scroll::ScrollbarAxis::Vertical)
.scrollbar_show(gpui_component::scroll::ScrollbarShow::Always)
)
})),
)
),
)
}
@@ -3815,15 +3963,13 @@ impl Render for Ashell {
.overflow_hidden()
.child(
v_flex()
.h(px(60.))
.h(px(40.))
.w_full()
.flex_none()
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.child(
h_flex()
.h(px(48.))
.h_full()
.w_full()
.items_center()
.gap_2()
@@ -3833,9 +3979,10 @@ impl Render for Ashell {
.flex_1()
.min_w(px(0.))
.h_full()
.overflow_x_scrollbar()
.overflow_x_hidden()
.child(
TabBar::new("ashell-tab-bar")
.track_scroll(&self.tabs_scroll_handle)
.selected_index(active_tab_index.unwrap_or(0))
.children(self.tabs.iter().enumerate().map(|(ix, tab)| {
let id = tab.id.clone();
@@ -3913,6 +4060,7 @@ impl Render for Ashell {
.track_focus(&self.focus_handle)
.key_context(TERMINAL_KEY_CONTEXT)
.on_mouse_down(MouseButton::Left, cx.listener(Self::focus_terminal))
.on_mouse_down(MouseButton::Right, cx.listener(Self::on_terminal_right_click))
.on_mouse_move(cx.listener(Self::on_terminal_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_terminal_mouse_up))
.on_key_down(cx.listener(Self::on_terminal_key_down))
@@ -4082,14 +4230,14 @@ impl Render for Ashell {
.child(
Button::new("ssh-connect-progress-retry")
.primary()
.label("retry")
.label(t!("retry").to_string())
.on_click(cx.listener(|this, _, _, cx| {
this.retry_connection_progress(cx)
})),
)
.child(
Button::new("ssh-connect-progress-close")
.label("cancel")
.label(t!("cancel").to_string())
.on_click(cx.listener(|this, _, _, cx| {
this.cancel_connection_progress(cx)
})),