Compare commits

..
14 Commits
Author SHA1 Message Date
TomZz c18cc55f86 ci: automate homebrew cask release for macos 2026-06-17 05:43:20 +08:00
TomZz 9c43b2dde7 chore: release v0.4.4 2026-06-17 05:00:51 +08:00
TomZz e6134bc0d1 fix: sftp panel status bar layout bug, update localized strings and add Open Transfer History shortcut 2026-06-17 04:58:27 +08:00
TomZz 96cd85a8d3 feat: persist sidebar and sftp panel visibility states 2026-06-17 03:00:44 +08:00
TomZz f45c2ed60f chore: release 2026-06-17 02:46:36 +08:00
TomZz 8a8f280401 feat: group keybindings and set keybindings page as default in settings 2026-06-17 02:43:10 +08:00
TomZz dc006f7144 fix: explicitly set mac quit mode to LastWindowClosed 2026-06-17 02:16:05 +08:00
TomZz daaebe4c2b feat: add sidebar collapsible feature 2026-06-17 00:43:39 +08:00
TomZz 2a37f45ba2 Merge pull request #35 from ancion/main
fix splitter resize bug and sftp status bar show file transfer progress
2026-06-16 22:54:07 +08:00
ancion ee8c18844c fix: sftp status not show sometimes and file transfer progress bar 2026-06-16 18:49:48 +08:00
ancion c5b09628b3 fix: panel splitter can drag to resize 2026-06-16 18:49:48 +08:00
TomZz b45364331d fix: restore full terminal width in split panes
Use the outer terminal panel bounds for PTY resizing while keeping per-pane bounds for focus and input handling. This prevents split panes from being resized a second time to half width.
2026-06-16 04:38:01 +08:00
TomZz 2d92f0ceee chore(release): v0.4.2
Bump the project version to v0.4.2 and include the updated README release notes in both Chinese and English.
2026-06-16 04:27:50 +08:00
TomZz 162a245a8f 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
2026-06-16 04:16:13 +08:00
19 changed files with 1039 additions and 273 deletions
+71
View File
@@ -168,3 +168,74 @@ jobs:
files: dist/**/*
generate_release_notes: true
fail_on_unmatched_files: true
cask:
name: Update Homebrew Cask
needs: publish
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout Homebrew Tap
uses: actions/checkout@v4
with:
repository: rust-kotlin/homebrew-taps
token: ${{ secrets.TAP_GITHUB_TOKEN }}
path: tap
- name: Download all macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: macos-*
path: dist
- name: Update Cask
run: |
VERSION=${GITHUB_REF#refs/tags/v}
SHA_ARM=$(sha256sum dist/macos-aarch64/*.zip | cut -d' ' -f1)
SHA_INTEL=$(sha256sum dist/macos-x86_64/*.zip | cut -d' ' -f1)
mkdir -p tap/Casks
CASK_FILE="tap/Casks/ashell.rb"
if [ ! -f "$CASK_FILE" ]; then
cat > "$CASK_FILE" <<'EOF'
cask "ashell" do
version "0.0.0"
on_arm do
sha256 "0"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-#{version}-macos-aarch64.zip"
end
on_intel do
sha256 "0"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-#{version}-macos-x86_64.zip"
end
name "ashell"
desc "ashell is a modern, fast, GPUI Component-based desktop terminal client written in Rust."
homepage "https://github.com/rust-kotlin/ashell"
app "ashell.app"
end
EOF
# Remove the 10 leading spaces added for YAML formatting
sed -i 's/^ //' "$CASK_FILE"
fi
sed -i "s|version \".*\"|version \"${VERSION}\"|" "$CASK_FILE"
sed -i "/on_arm do/,/end/ s|sha256 \".*\"|sha256 \"${SHA_ARM}\"|" "$CASK_FILE"
sed -i "/on_intel do/,/end/ s|sha256 \".*\"|sha256 \"${SHA_INTEL}\"|" "$CASK_FILE"
- name: Commit and Push
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [[ -n $(git status -s) ]]; then
git add Casks/ashell.rb
git commit -m "chore: bump ashell to v${VERSION}"
git push
else
echo "No changes to commit"
fi
Generated
+1 -1
View File
@@ -303,7 +303,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.1"
version = "0.4.4"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.1"
version = "0.4.4"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
+9 -8
View File
@@ -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
+9 -8
View File
@@ -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 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
## 运行
+10
View File
@@ -124,6 +124,10 @@ settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_other: "Other"
settings_group_keybind_general: "General"
settings_group_keybind_zoom: "Zoom"
settings_group_keybind_focus: "Focus"
settings_group_keybind_panel: "Panel"
theme: "Theme"
theme_mode: "Theme Mode"
clear_transfers: "Clear History"
@@ -132,7 +136,13 @@ transfers_limit: " (Max 100 records)"
press_new_key: "Press new key..."
settings_open_settings: "Open Settings"
settings_open_session: "Open Session"
settings_open_transfers: "Open Transfer History"
files_downloading: "%{count} files downloading"
files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_focus_pane_left: "Focus Pane Left"
settings_focus_pane_right: "Focus Pane Right"
+11 -2
View File
@@ -23,7 +23,6 @@ system_path_warning: "警告:您即将删除系统关键路径!这可能会
delete_success: "成功删除 %{count} 个项"
delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
cancel: "取消"
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
@@ -127,6 +126,10 @@ settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_other: "其它"
settings_group_keybind_general: "常规"
settings_group_keybind_zoom: "缩放"
settings_group_keybind_focus: "焦点"
settings_group_keybind_panel: "面板"
theme: "主题"
theme_mode: "主题模式"
clear_transfers: "清空记录"
@@ -135,7 +138,13 @@ transfers_limit: " (最多保留 100 条)"
press_new_key: "请按下新的快捷键..."
settings_open_settings: "打开设置"
settings_open_session: "打开会话"
settings_open_transfers: "打开传输历史"
files_downloading: "%{count} 个文件正在下载"
files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
settings_toggle_sidebar: "缩放侧边栏"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
settings_focus_pane_left: "向左切换焦点"
settings_focus_pane_right: "向右切换焦点"
@@ -146,4 +155,4 @@ settings_split_pane_right: "向右拆分面板"
settings_split_pane_up: "向上拆分面板"
settings_split_pane_down: "向下拆分面板"
settings_close_pane: "关闭当前面板"
keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键"
keybind_conflict: '"%{key}" 已被「%{action}」使用,请选择其他快捷键'
+1
View File
@@ -1,6 +1,7 @@
pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0;
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
+65 -7
View File
@@ -21,6 +21,11 @@ use crate::{Ashell, session::config::AuthMethod, system::format_bytes};
impl Ashell {
pub(crate) fn show_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() {
return;
}
self.active_dialog = Some(crate::app::DialogKind::NewSsh);
let view = cx.entity();
let session_name_input = self.session_name_input.clone();
let host_input = self.host_input.clone();
@@ -30,12 +35,22 @@ 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
.title(t!("new_ssh_connection"))
.w(px(520.))
.overlay_closable(true)
.on_close({
let view = view.clone();
move |_, _, cx| {
view.update(cx, |this, cx| {
this.active_dialog = None;
cx.notify();
});
}
})
.content({
let view = view.clone();
let session_name_input = session_name_input.clone();
@@ -45,6 +60,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 +130,7 @@ impl Ashell {
),
)
.child(
Input::new(&key_path_input).tab_index(5),
Input::new(&key_path_input).tab_index(4),
),
)
.child(
@@ -134,7 +150,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()
@@ -170,6 +187,11 @@ impl Ashell {
});
}
pub(crate) fn show_selector_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() {
return;
}
self.active_dialog = Some(crate::app::DialogKind::SessionSelector);
let view = cx.entity();
let selector_focus_handle = self.selector_focus_handle.clone();
let deferred_selector_focus_handle = selector_focus_handle.clone();
@@ -180,6 +202,15 @@ impl Ashell {
dialog
.title(t!("open_session").to_string())
.w(px(520.))
.on_close({
let view = view.clone();
move |_, _, cx| {
view.update(cx, |this, cx| {
this.active_dialog = None;
cx.notify();
});
}
})
.on_ok({
let view = view.clone();
move |_, window, cx| {
@@ -402,9 +433,26 @@ impl Ashell {
});
}
pub(crate) fn show_transfers_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() {
return;
}
self.active_dialog = Some(crate::app::DialogKind::Transfers);
let view = cx.entity();
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
dialog.w(px(600.)).close_button(false).content({
dialog
.w(px(600.))
.close_button(false)
.on_close({
let view = view.clone();
move |_, _, cx| {
view.update(cx, |this, cx| {
this.active_dialog = None;
cx.notify();
});
}
})
.content({
let view = view.clone();
move |content, window, cx| {
let can_clear = view.read(cx).transfers.iter().any(|t| {
@@ -994,6 +1042,11 @@ impl Ashell {
});
}
pub(crate) fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() {
return;
}
self.active_dialog = Some(crate::app::DialogKind::Settings);
let view = cx.entity();
// Unbind all workspace keys so they don't interfere with keybinding recording
@@ -1010,6 +1063,7 @@ impl Ashell {
move |_, _window, cx| {
// Re-register all workspace keys when closing settings
view.update(cx, |this, cx| {
this.active_dialog = None;
this.keybinds_suspended = false;
this.recording_action = None;
this.keybind_error = None;
@@ -1538,11 +1592,15 @@ impl Ashell {
)
)
)
.page(
SettingPage::new(t!("settings_key_bindings").to_string())
.page({
let mut page = SettingPage::new(t!("settings_key_bindings").to_string())
.icon(IconName::SquareTerminal)
.group(crate::app::keybinding_recorder::KeybindingsPage::render(&view, cx))
)
.default_open(true);
for group in crate::app::keybinding_recorder::KeybindingsPage::render_groups(&view, cx) {
page = page.group(group);
}
page
})
.page(
SettingPage::new(t!("settings_help").to_string())
.icon(IconName::BookOpen)
+115 -58
View File
@@ -16,7 +16,9 @@ gpui::actions!(
[
OpenSettings,
OpenSession,
OpenTransfers,
NewSsh,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
FocusPaneRight,
@@ -50,11 +52,21 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
label_key: "settings_open_session",
default_suffix: "o",
},
WorkspaceAction {
id: "OpenTransfers",
label_key: "settings_open_transfers",
default_suffix: "t",
},
WorkspaceAction {
id: "NewSsh",
label_key: "settings_new_ssh",
default_suffix: "n",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
default_suffix: "s",
},
WorkspaceAction {
id: "ToggleSftpZoom",
label_key: "settings_toggle_sftp_zoom",
@@ -192,7 +204,9 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
unbind_action!("OpenSettings", crate::OpenSettings);
unbind_action!("OpenSession", crate::OpenSession);
unbind_action!("OpenTransfers", crate::OpenTransfers);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
unbind_action!("FocusPaneRight", crate::FocusPaneRight);
@@ -245,7 +259,9 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("OpenSettings", crate::OpenSettings);
bind_action!("OpenSession", crate::OpenSession);
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
bind_action!("FocusPaneRight", crate::FocusPaneRight);
@@ -261,74 +277,115 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
}
impl KeybindingsPage {
pub fn render(view: &Entity<Ashell>, cx: &mut App) -> SettingGroup {
let mut group = SettingGroup::new();
pub fn render_groups(view: &Entity<Ashell>, cx: &mut App) -> Vec<SettingGroup> {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
),
(
"settings_group_keybind_zoom",
vec!["ToggleSidebar", "ToggleSftpZoom"],
),
(
"settings_group_keybind_focus",
vec![
"FocusPaneLeft",
"FocusPaneRight",
"FocusPaneUp",
"FocusPaneDown",
],
),
(
"settings_group_keybind_panel",
vec![
"SplitPaneLeft",
"SplitPaneRight",
"SplitPaneUp",
"SplitPaneDown",
"ClosePane",
],
),
];
for action in WORKSPACE_ACTIONS {
let recording = view.read(cx).recording_action.as_deref() == Some(action.id);
let has_error = view
.read(cx)
.keybind_error
.as_ref()
.is_some_and(|(id, _)| id == action.id);
let error_msg = if has_error {
view.read(cx)
let mut result = Vec::new();
for (group_label, action_ids) in groups {
let mut group = SettingGroup::new().title(t!(group_label).to_string());
for action_id in action_ids {
let action = WORKSPACE_ACTIONS
.iter()
.find(|a| a.id == action_id)
.expect("action exists");
let recording = view.read(cx).recording_action.as_deref() == Some(action.id);
let has_error = view
.read(cx)
.keybind_error
.as_ref()
.map(|(_, msg)| msg.clone())
} else {
None
};
.is_some_and(|(id, _)| id == action.id);
let error_msg = if has_error {
view.read(cx)
.keybind_error
.as_ref()
.map(|(_, msg)| msg.clone())
} else {
None
};
let keystroke = {
let config = &view.read(cx).config;
configured_keystroke(config, action.id).unwrap_or_default()
};
let keystroke = {
let config = &view.read(cx).config;
configured_keystroke(config, action.id).unwrap_or_default()
};
let btn_label = if recording {
t!("press_new_key").to_string()
} else if keystroke.is_empty() {
t!("none").to_string()
} else {
format_keystroke(&keystroke)
};
let btn_label = if recording {
t!("press_new_key").to_string()
} else if keystroke.is_empty() {
t!("none").to_string()
} else {
format_keystroke(&keystroke)
};
let mut item = SettingItem::new(
t!(action.label_key).to_string(),
SettingField::render({
let view = view.clone();
let action_id = action.id.to_string();
move |_, _window, _cx| {
Button::new(gpui::SharedString::from(format!("keybind-{action_id}")))
.label(btn_label.clone())
.small()
.when(recording, |this| this.primary())
.when(has_error, |this| this.danger())
.on_click({
let view = view.clone();
let action_id = action_id.clone();
move |_event, window, cx| {
view.update(cx, |this, cx| {
// Clear any previous error when starting new recording
this.keybind_error = None;
this.recording_action = Some(action_id.clone());
this.focus_handle.focus(window, cx);
cx.notify();
});
}
})
.into_any_element()
}
}),
);
let mut item = SettingItem::new(
t!(action.label_key).to_string(),
SettingField::render({
let view = view.clone();
let action_id = action.id.to_string();
move |_, _window, _cx| {
Button::new(gpui::SharedString::from(format!("keybind-{action_id}")))
.label(btn_label.clone())
.small()
.when(recording, |this| this.primary())
.when(has_error, |this| this.danger())
.on_click({
let view = view.clone();
let action_id = action_id.clone();
move |_event, window, cx| {
view.update(cx, |this, cx| {
// Clear any previous error when starting new recording
this.keybind_error = None;
this.recording_action = Some(action_id.clone());
this.focus_handle.focus(window, cx);
cx.notify();
});
}
})
.into_any_element()
}
}),
);
if let Some(msg) = error_msg {
item = item.description(msg);
if let Some(msg) = error_msg {
item = item.description(msg);
}
group = group.item(item);
}
group = group.item(item);
result.push(group);
}
group
result
}
}
+32 -10
View File
@@ -189,6 +189,14 @@ impl ScrollbarHandle for TerminalScrollbarHandle {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DialogKind {
Settings,
SessionSelector,
Transfers,
NewSsh,
}
pub(crate) struct Ashell {
pub(crate) focus_handle: FocusHandle,
pub(crate) selector_focus_handle: FocusHandle,
@@ -199,6 +207,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,17 +246,21 @@ 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)
pub(crate) drag_split_origin: Option<gpui::Point<Pixels>>,
pub(crate) terminal_marked_text: Option<String>,
pub(crate) sftp_panel_minimized: bool,
pub(crate) sidebar_collapsed: bool,
pub(crate) collapsed_saved_scroll_handle: gpui::ScrollHandle,
pub(crate) prev_monitoring_size: Option<Pixels>,
pub(crate) status: SharedString,
pub(crate) config: ConfigStore,
pub(crate) system_sampler: SystemSampler,
pub(crate) recording_action: Option<String>,
pub(crate) active_dialog: Option<DialogKind>,
/// Error message when a recorded keybinding conflicts with another
pub(crate) keybind_error: Option<(String, String)>, // (action_id, error_message)
/// Whether workspace keybindings are currently suspended (during settings)
@@ -331,6 +344,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 +361,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 +377,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 +419,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 +437,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 +459,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
@@ -457,11 +478,14 @@ impl Ashell {
dragging_splitter: None,
drag_split_origin: None,
sftp_panel_minimized: false,
sidebar_collapsed: config.sidebar_collapsed(),
collapsed_saved_scroll_handle: gpui::ScrollHandle::new(),
prev_monitoring_size: None,
status: "ready".into(),
config,
system_sampler,
recording_action: None,
active_dialog: None,
keybind_error: None,
keybinds_suspended: false,
system,
@@ -868,6 +892,4 @@ impl Ashell {
self.config.set_transfers(self.transfers.clone());
cx.notify();
}
}
+26 -1
View File
@@ -239,6 +239,20 @@ pub(crate) fn open_main_window(cx: &mut App) {
let view = cx.new(|cx| Ashell::new(window, cx));
tracing::info!("[ui] main application window opened");
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let sftp_minimized = view.read(cx).config.sftp_panel_minimized();
if sftp_minimized {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
view.update(cx, |this, cx| {
this.toggle_sftp_minimized(window, cx);
});
}
});
}
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
@@ -283,13 +297,24 @@ pub(crate) fn open_main_window(cx: &mut App) {
.iter()
.map(|s| s.into())
.collect();
let body_sizes: Vec<f32> = body_panels_clone
let mut body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if view_clone.read(cx).sftp_panel_minimized {
if let Some(prev) = view_clone.read(cx).prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(view_clone.read(cx).sidebar_collapsed);
config.set_sftp_panel_minimized(view_clone.read(cx).sftp_panel_minimized);
let _ = config.save();
true
});
+546 -145
View File
@@ -5,7 +5,7 @@ use gpui::{
prelude::FluentBuilder as _, px, rems, uniform_list,
};
use gpui_component::{
ActiveTheme, Disableable as _, ElementExt, IconName, Root, Sizable as _,
ActiveTheme, Disableable as _, ElementExt, Icon, IconName, Root, Size, Sizable as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
h_flex,
@@ -21,7 +21,7 @@ use rust_i18n::t;
use crate::{
Ashell, PaneLayout,
app::constants::{SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT},
app::constants::{COLLAPSED_SIDEBAR_WIDTH, SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT},
sftp::format_mtime,
sftp::ops::is_editable_text_file,
system::format_bytes,
@@ -108,6 +108,9 @@ impl Ashell {
},
);
}
self.config
.set_sftp_panel_minimized(self.sftp_panel_minimized);
let _ = self.config.save();
cx.notify();
}
@@ -118,9 +121,65 @@ impl Ashell {
) -> impl IntoElement {
let active_sftp = self.active_sftp();
// Compute active download progress for status bar and minimized header
let build_summary = |kind: crate::terminal::TransferType| -> Option<(String, String, f32)> {
let active: Vec<&crate::terminal::Transfer> = self
.transfers
.iter()
.filter(|t| {
matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
) && t.info.kind == kind
})
.collect();
if active.is_empty() {
return None;
}
Some(if active.len() == 1 {
let t = &active[0];
let pct = t.total.and_then(|total| {
if total > 0 {
Some((t.transferred as f64 / total as f64 * 100.0) as f32)
} else {
None
}
});
match pct {
Some(pct) => (t.info.name.clone(), format!("{:.0}%", pct), pct),
None => (t.info.name.clone(), "-".to_string(), 0.0),
}
} else {
let total_transferred: u64 = active.iter().map(|t| t.transferred).sum();
let total_total: u64 = active.iter().filter_map(|t| t.total).sum();
let pct = if total_total > 0 {
Some((total_transferred as f64 / total_total as f64 * 100.0) as f32)
} else {
None
};
let label = match kind {
crate::terminal::TransferType::Download => {
t!("files_downloading", count = active.len()).to_string()
}
crate::terminal::TransferType::Upload => {
t!("files_uploading", count = active.len()).to_string()
}
};
match pct {
Some(pct) => (label, format!("{:.0}%", pct), pct),
None => (label, "-".to_string(), 0.0),
}
})
};
let dl_summary = build_summary(crate::terminal::TransferType::Download);
let ul_summary = build_summary(crate::terminal::TransferType::Upload);
let has_transfers = dl_summary.is_some() || ul_summary.is_some();
let header = h_flex()
.flex_none()
.h(px(34.))
.px_2()
.items_center()
.gap_2()
.border_b_1()
@@ -226,61 +285,131 @@ impl Ashell {
cx.notify();
})),
)
})
.child(
Button::new("open-transfers")
.ghost()
.small()
.icon(IconName::ArrowDown)
.label(t!("transfers").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.show_transfers_dialog(window, cx);
})),
)
.child(
Button::new("sftp-minimize-toggle")
.ghost()
.small()
.icon(if self.sftp_panel_minimized {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_sftp_minimized(window, cx);
})),
);
});
let Some(sftp) = active_sftp else {
let mut panel = v_flex()
let mut outer = v_flex()
.gap_0()
.border_color(cx.theme().border)
.bg(cx.theme().background);
if !self.sftp_panel_minimized {
panel = panel.size_full();
} else {
panel = panel.flex_none();
}
panel = panel.child(header);
if !self.sftp_panel_minimized {
panel = panel.child(
.bg(cx.theme().background)
.flex_1()
.child(
v_flex()
.flex_1()
.items_center()
.justify_center()
.p_3()
.min_h(px(0.))
.when(self.sftp_panel_minimized, |this| this.hidden())
.child(header)
.child(
div()
.text_size(rems(1.0))
.text_color(cx.theme().muted_foreground)
.child(t!("open_ssh_tab_sftp")),
v_flex()
.flex_1()
.items_center()
.justify_center()
.p_3()
.child(
div()
.text_size(rems(1.0))
.text_color(cx.theme().muted_foreground)
.child(t!("open_ssh_tab_sftp")),
),
),
);
}
return panel.into_any_element();
outer = outer.child(
h_flex()
.flex_none()
.h(px(24.))
.px_3()
.items_center()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.child(div().flex_1())
.child(
Button::new("open-transfers")
.ghost()
.small()
.when(has_transfers, |this| {
let mut content = h_flex().items_center().gap_2();
if let Some((ref label, ref pct_display, pct)) = dl_summary {
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowDown)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-dl")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
);
}
if let Some((ref label, ref pct_display, pct)) = ul_summary {
if dl_summary.is_some() {
content = content.child(div().w(px(6.)));
}
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowUp)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-ul")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
);
}
this.child(content)
.on_click(cx.listener(|this, _, window, cx| {
this.show_transfers_dialog(window, cx);
}))
})
.when(!has_transfers, |this| {
this.icon(IconName::ArrowDown)
.label(t!("transfers").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.show_transfers_dialog(window, cx);
}))
}),
)
.child(
Button::new("sftp-minimize-toggle")
.ghost()
.small()
.icon(if self.sftp_panel_minimized {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.label(if self.sftp_panel_minimized {
t!("panel_expand").to_string()
} else {
t!("panel_minimize").to_string()
})
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_sftp_minimized(window, cx);
})),
),
);
return outer.into_any_element();
};
let selected_path = sftp.selected_path.clone();
@@ -302,18 +431,11 @@ impl Ashell {
let size_col_width = px(96.);
let modified_col_width = px(152.);
let mut panel = v_flex()
let mut outer = v_flex()
.gap_0()
.border_color(cx.theme().border)
.bg(cx.theme().background);
if !self.sftp_panel_minimized {
panel = panel.size_full();
} else {
panel = panel.flex_none();
}
panel = panel
.bg(cx.theme().background)
.flex_1()
.on_drop(
cx.listener(|this, paths: &gpui::ExternalPaths, _window, cx| {
let paths_to_upload: Vec<String> = paths
@@ -323,11 +445,14 @@ impl Ashell {
.collect();
this.upload_sftp_files_batch(paths_to_upload, cx);
}),
)
.child(header);
);
if !self.sftp_panel_minimized {
panel = panel
outer = outer.child(
v_flex()
.flex_1()
.min_h(px(0.))
.when(self.sftp_panel_minimized, |this| this.hidden())
.child(header)
.child(
h_flex()
.h(px(36.))
@@ -592,27 +717,116 @@ impl Ashell {
),
),
)
.child(
h_flex()
.flex_none()
.h(px(24.))
.px_3()
.items_center()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.child(
div()
.min_w(px(0.))
.overflow_hidden()
.text_size(rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(status),
),
);
}
outer = outer.child(
h_flex()
.flex_none()
.h(px(24.))
.px_3()
.items_center()
.border_t_1()
.border_color(cx.theme().border)
.bg(cx.theme().tab_bar)
.child(
div()
.flex_1()
.min_w(px(0.))
.overflow_hidden()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(status),
)
.child(
Button::new("open-transfers")
.ghost()
.small()
.when(has_transfers, |this| {
let mut content = h_flex().items_center().gap_2();
if let Some((ref label, ref pct_display, pct)) = dl_summary {
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowDown)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-dl")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
);
}
if let Some((ref label, ref pct_display, pct)) = ul_summary {
if dl_summary.is_some() {
content = content.child(div().w(px(6.)));
}
content = content.child(
h_flex().items_center().gap_1()
.child(Icon::new(IconName::ArrowUp)
.with_size(Size::Small)
.text_color(cx.theme().primary))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(label.clone()))
.child(Progress::new("sftp-status-ul")
.with_size(px(4.))
.value(pct)
.color(cx.theme().primary)
.w(px(50.0)))
.child(div()
.text_size(rems(0.833))
.text_color(cx.theme().primary)
.italic()
.child(pct_display.clone())),
);
}
this.child(content)
})
.when(!has_transfers, |this| {
this.icon(IconName::ArrowDown)
.label(t!("transfers").to_string())
})
.on_click(cx.listener(|this, _, window, cx| {
this.show_transfers_dialog(window, cx);
})),
panel.into_any_element()
)
.child(
Button::new("sftp-minimize-toggle")
.ghost()
.small()
.icon(if self.sftp_panel_minimized {
IconName::ChevronUp
} else {
IconName::ChevronDown
})
.label(if self.sftp_panel_minimized {
t!("panel_expand").to_string()
} else {
t!("panel_minimize").to_string()
})
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_sftp_minimized(window, cx);
})),
),
);
outer.into_any_element()
}
fn render_monitoring_panel(
@@ -1323,6 +1537,17 @@ impl Ashell {
.child("Ashell"),
)
.child(div().flex_1())
.child(
Button::new("sidebar-collapse-toggle")
.ghost()
.icon(IconName::PanelLeftClose)
.on_click(cx.listener(|this, _, _, cx| {
this.sidebar_collapsed = true;
this.config.set_sidebar_collapsed(true);
let _ = this.config.save();
cx.notify();
})),
)
.child(
Button::new("sidebar-settings")
.ghost()
@@ -1521,6 +1746,153 @@ impl Ashell {
)
}
fn render_collapsed_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let sessions = self.config.sessions().to_vec();
let active_session_id = self.active_session_id().map(ToOwned::to_owned);
v_flex()
.w_full()
.h_full()
.min_w(px(0.))
.p_2()
.border_r_1()
.border_color(cx.theme().sidebar_border)
.bg(cx.theme().sidebar)
.overflow_hidden()
.items_center()
// Top: expand button only
.child(
div()
.w_full()
.flex()
.items_center()
.justify_center()
.pb_2()
.child(
Button::new("sidebar-expand-toggle")
.ghost()
.icon(IconName::PanelLeftOpen)
.on_click(cx.listener(|this, _, _, cx| {
this.sidebar_collapsed = false;
this.config.set_sidebar_collapsed(false);
let _ = this.config.save();
cx.notify();
})),
),
)
// Saved sessions as compact cards
.child(
div()
.relative()
.flex_1()
.min_h(px(0.))
.w_full()
.child(
v_flex()
.size_full()
.id("collapsed-saved-sessions-scroll")
.track_scroll(&self.collapsed_saved_scroll_handle)
.overflow_y_scroll()
.gap_2()
.items_center()
.children(sessions.into_iter().enumerate().map(
|(ix, session)| {
let connect_id = session.id.clone();
let is_active = active_session_id.as_deref()
== Some(session.id.as_str());
let name = session.name.clone();
// Abbreviate: first 1 char for CJK, first 2 chars for Latin
let abbrev = {
let mut chars = name.chars();
if let Some(first) = chars.next() {
if first > '\u{2E7F}' {
// CJK character range — show 1 char
first.to_string()
} else {
// Latin / ASCII — show first 2 chars
let mut s = first.to_string();
if let Some(second) = chars.next() {
s.push(second);
}
s
}
} else {
"?".to_string()
}
};
div()
.id(("collapsed-saved", ix))
.w(px(36.))
.h(px(36.))
.flex()
.items_center()
.justify_center()
.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,
)
}),
)
.tooltip({
let tooltip_text = format!("{} {}", name, session.user);
move |window, cx| {
gpui_component::tooltip::Tooltip::new(tooltip_text.clone()).build(window, cx)
}
})
.child(
div()
.text_size(rems(0.833))
.font_weight(FontWeight::BOLD)
.text_color(if is_active {
cx.theme().primary
} else {
cx.theme().foreground
})
.child(abbrev),
)
},
)),
)
.child(
div()
.absolute()
.top_0()
.bottom_0()
.left_0()
.right_0()
.child(
gpui_component::scroll::Scrollbar::new(
&self.collapsed_saved_scroll_handle,
)
.id("collapsed-saved-scrollbar")
.axis(gpui_component::scroll::ScrollbarAxis::Vertical)
.scrollbar_show(
gpui_component::scroll::ScrollbarShow::Scrolling,
),
),
),
)
}
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let active_tab_index = self
.active_tab
@@ -1692,10 +2064,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)
@@ -1852,8 +2230,7 @@ impl Ashell {
.children(children.iter().enumerate().flat_map(|(i, child)| {
let mut items: Vec<gpui::AnyElement> = Vec::new();
if i > 0 {
let mut splitter_path = path.to_vec();
splitter_path.push(i - 1); // path to the pane BEFORE this splitter
let splitter_path = path.to_vec(); // path to the CONTAINER that has the ratio
items.push(
div()
.h(px(4.))
@@ -1902,15 +2279,14 @@ impl Ashell {
.size_full()
.children(children.iter().enumerate().flat_map(|(i, child)| {
let mut items: Vec<gpui::AnyElement> = Vec::new();
if i > 0 {
let mut splitter_path = path.to_vec();
splitter_path.push(i - 1);
items.push(
div()
.w(px(4.))
.h_full()
.flex_none()
.cursor_col_resize()
if i > 0 {
let splitter_path = path.to_vec(); // path to the CONTAINER that has the ratio
items.push(
div()
.w(px(4.))
.h_full()
.flex_none()
.cursor_col_resize()
.bg(cx.theme().border)
.hover(|s| s.bg(cx.theme().accent))
.on_mouse_down(
@@ -1990,16 +2366,6 @@ impl Render for Ashell {
}
}
let sidebar_area = resizable_panel()
.size(px(self
.config
.workspace_panels()
.and_then(|s| s.first().copied())
.unwrap_or(SIDEBAR_WIDTH)))
.size_range(px(240.)..px(520.))
.flex_none()
.child(self.sidebar(cx));
let monitoring_contents = v_flex()
.size_full()
.when(self.config.monitoring_position() == "Bottom", |this| {
@@ -2008,61 +2374,89 @@ impl Render for Ashell {
.child(self.render_sftp_panel(window, cx));
let is_monitor_bottom = self.config.monitoring_position() == "Bottom";
let minimized_height = if is_monitor_bottom { 114. } else { 34. };
let minimized_height = if is_monitor_bottom { 104. } else { 24. };
let min_panel_height = if is_monitor_bottom { 260. } else { 180. };
let default_panel_height = if is_monitor_bottom { 328. } else { 248. };
let body_panel = if self.sftp_panel_minimized {
v_flex()
let sftp_size = if self.sftp_panel_minimized {
px(minimized_height)
} else {
px(self
.config
.body_panels()
.and_then(|s| s.get(1).copied())
.unwrap_or(default_panel_height))
};
let body_panel = v_resizable("ashell-body")
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(cx)))
.child(
resizable_panel()
.size(sftp_size)
.size_range(
if self.sftp_panel_minimized {
px(minimized_height)..px(minimized_height)
} else {
px(min_panel_height)..px(1200.)
},
)
.child(monitoring_contents),
)
.into_any_element();
let workspace = if self.sidebar_collapsed {
h_flex()
.size_full()
.child(
div().flex_1().min_h(px(0.)).child(
v_resizable("ashell-body")
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(cx))),
),
)
.child(
div()
.flex_none()
.h(px(minimized_height))
.w_full()
.border_t_1()
.border_color(cx.theme().border)
.child(monitoring_contents),
.w(px(COLLAPSED_SIDEBAR_WIDTH))
.h_full()
.child(self.render_collapsed_sidebar(cx)),
)
.child(
div()
.flex_1()
.h_full()
.min_w(px(0.))
.child(
v_flex()
.size_full()
.relative()
.overflow_hidden()
.child(self.render_tab_bar(cx))
.child(body_panel),
),
)
.into_any_element()
} else {
v_resizable("ashell-body")
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(cx)))
.child(
resizable_panel()
.size(px(self
.config
.body_panels()
.and_then(|s| s.get(1).copied())
.unwrap_or(default_panel_height)))
.size_range(px(min_panel_height)..px(1200.))
.child(monitoring_contents),
)
let sidebar_area = resizable_panel()
.size(px(self
.config
.workspace_panels()
.and_then(|s| s.first().copied())
.unwrap_or(SIDEBAR_WIDTH)))
.size_range(px(240.)..px(520.))
.flex_none()
.child(self.sidebar(cx));
let main_area = resizable_panel().child(
v_flex()
.size_full()
.relative()
.overflow_hidden()
.child(self.render_tab_bar(cx))
.child(body_panel),
);
h_resizable("ashell-workspace")
.with_state(&self.workspace_panels)
.child(sidebar_area)
.child(main_area)
.into_any_element()
};
let main_area = resizable_panel().child(
v_flex()
.size_full()
.relative()
.overflow_hidden()
.child(self.render_tab_bar(cx))
.child(body_panel),
);
let workspace = h_resizable("ashell-workspace")
.with_state(&self.workspace_panels)
.child(sidebar_area)
.child(main_area);
div()
.id("ashell-root")
.size_full()
@@ -2071,7 +2465,14 @@ impl Render for Ashell {
.font_family(self.ui_font_family.clone())
.on_action(cx.listener(|this, _: &crate::OpenSettings, window, cx| this.show_settings_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::OpenSession, window, cx| this.show_selector_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::OpenTransfers, window, cx| this.show_transfers_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::NewSsh, window, cx| this.show_ssh_dialog(window, cx)))
.on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| {
this.sidebar_collapsed = !this.sidebar_collapsed;
this.config.set_sidebar_collapsed(this.sidebar_collapsed);
let _ = this.config.save();
cx.notify();
}))
.on_action(cx.listener(|this, _: &crate::ToggleSftpZoom, window, cx| {
this.toggle_sftp_minimized(window, cx);
}))
+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())),
}
+4 -2
View File
@@ -16,7 +16,8 @@ gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
OpenSettings, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp, ToggleSftpZoom,
OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp,
ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::{
@@ -30,10 +31,11 @@ fn main() {
#[cfg(target_os = "macos")]
let app = gpui_platform::application()
.with_assets(Assets)
.with_quit_mode(gpui::QuitMode::Explicit);
.with_quit_mode(gpui::QuitMode::LastWindowClosed);
#[cfg(not(target_os = "macos"))]
let app = gpui_platform::application().with_assets(Assets);
app.on_reopen(|cx| {
if cx.windows().is_empty() {
app::startup::open_main_window(cx);
+50 -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,
@@ -130,6 +135,10 @@ pub struct ConfigFile {
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
#[serde(default)]
pub sidebar_collapsed: bool,
#[serde(default)]
pub sftp_panel_minimized: bool,
#[serde(default)]
pub key_bindings: std::collections::HashMap<String, String>,
}
@@ -137,6 +146,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 +201,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()
};
@@ -382,6 +414,22 @@ impl ConfigStore {
self.cache.show_hidden_files = val;
}
pub fn sidebar_collapsed(&self) -> bool {
self.cache.sidebar_collapsed
}
pub fn set_sidebar_collapsed(&mut self, val: bool) {
self.cache.sidebar_collapsed = val;
}
pub fn sftp_panel_minimized(&self) -> bool {
self.cache.sftp_panel_minimized
}
pub fn set_sftp_panel_minimized(&mut self, val: bool) {
self.cache.sftp_panel_minimized = val;
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.cache.sessions.iter().find(|s| s.id == id)
}
+29 -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,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
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)]