feat(ui): 重构工作区交互与系统菜单 (#111)

This commit is contained in:
Realm
2026-08-27 00:01:03 +08:00
committed by Tom Cruise
parent b4551ee1cc
commit ee98a933e6
18 changed files with 1329 additions and 798 deletions
+32 -5
View File
@@ -13,12 +13,15 @@
其他平台默认使用 `Ctrl+Q`
- 在设置页快捷键面板增加“退出应用”配置项,支持修改或取消绑定。
- 新增窗口位置、尺寸及最大化或全屏状态记忆,并在显示器布局变化后自动校正窗口位置。
- 新增系统级应用菜单,提供关于、文件、编辑、视图、窗口和帮助等常用操作。
- 新增“记忆标签页”设置,默认关闭。启用后可恢复标签组、分屏布局、活动标签、
本地工作目录和终端编码。
- 本地终端和 SSH 标签页新增独立编码切换,支持 UTF-8、GB18030、GBK、Big5、
Shift_JIS、EUC-KR 和 Windows-1252
- 新增终端编码切换,支持 UTF-8、GB18030、GBK、Big5、Shift_JIS、EUC-KR 和
Windows-1252;SSH 标签页可独立设置,本地终端通过设置面板统一管理
- 远程文件编辑器新增编码切换,额外支持 UTF-16 LE、UTF-16 BE、BOM 检测与保留。
- 新增 CSV 连接导入和导出功能。
- 连接管理新增分组功能,支持新建、重命名、删除、折叠和批量移动连接;
分组标题提供三态复选框,可按当前筛选结果批量选择或取消组内连接,删除分组时连接自动移至“未分组”。
- 连接列表新增筛选、批量选择、已选数量统计和批量删除功能。
- 新增全局 SSH 历史命令功能,支持筛选、选择、批量删除、复制和执行。
- 新增通用终端通知,支持 OSC 9、OSC 99、OSC 777 和 BEL,并在 macOS Dock 或
@@ -37,17 +40,26 @@
### 变更
- 将英文连接管理标题由 `CONNECTION MANAGEMENT` 简化为 `CONNECTIONS`
- 侧边栏折叠后,连接管理改为类似历史命令的弹出列表,保留筛选、勾选、分组、
批量删除和独立连接按钮
- 默认使用融合标题栏,并重新整理标签栏样式与操作区;折叠入口位于标签栏左侧,
搜索和设置入口位于右侧
- 工作区交互统一为下划线活动页签、平面连接列表和低干扰图标工具栏;标签过多时提供溢出菜单,
空连接列表可直接新建连接。
- 标签列表选择标签时,标签栏会以平滑动画定位到完整可见位置,同时保留手动滚动能力。
- SSH 监控区增加当前会话身份条,显示会话名称、连接地址和连接状态;断开时保留状态上下文,
但禁用远程资源详情入口。
- 侧边栏折叠后完全隐藏,不再保留折叠态连接图标或连接列表入口。
- 重新整理连接管理操作区,集中展示导入、导出和新建连接操作。
- 连接列表改为名称靠左、连接地址靠右的整行布局,并保持列表项宽度一致。
- 点击连接行只切换勾选状态,只有点击连接图标才发起连接。
- 编辑连接保存后不再自动打开该连接。
- 新建和编辑页面标题调整为“新建连接”和“编辑连接”。
- 新建和编辑 SSH、SSH Config 及串口连接时,分组字段改为下拉选择,
只能选择“未分组”或已有分组,并自动回显原分组。
- SSH 表单改为 IP 与端口同行、账号与密码同行,并增加默认终端编码设置。
- 右上角加号改为紧凑下拉菜单,仅保留“本地终端”和“新建连接”。
- CSV 导出格式精简为 `name,host,port,username,password`,不再导出 ID、认证类型、
- CSV 导出格式精简为 `name,group,host,port,username,password`,不再导出 ID、认证类型、
私钥路径、私钥内容和私钥口令。
- CSV 导入和导出支持可选连接分组,连接唯一标识仍使用 `host + port + username`
- CSV 导入支持常见字段别名、UTF-8 BOM、带引号内容和多行密码,端口为空时默认使用 `22`
- CSV 导入使用 `host + port + username` 识别同一连接,匹配时更新原配置并保留本地专用字段。
- 同一 CSV 中的重复连接以最后一条为准;本地存在多个相同标识时跳过更新并计入导入结果。
@@ -60,16 +72,20 @@
- 历史命令跨 SSH 标签页可用,当前连接的命令优先显示;超长命令保持单行并使用省略号。
- 点击历史命令行只切换勾选状态,执行和复制分别使用独立图标;执行后自动关闭弹出窗口。
- 相同历史命令自动去重并保留最新记录,每个 SSH 连接最多保存 200 条。
- SSH 终端编码入口移至历史按钮右侧;本地终端编码改由设置面板统一管理。
- 远程文件面板只在当前标签为 SSH 时显示,并随活动 SSH 标签切换对应的 SFTP 会话。
- SSH 当前目录变化时自动同步远程文件路径。
- 远程文件面板改为左侧目录树、右侧当前目录内容的双栏布局,分隔宽度可拖动并持久化。
- 面板头部整合传输记录、历史命令和更多操作,移除原底部传输栏。
- 精简远程文件面板头部,移除面板名称和左侧图标,并保留带文字的常用操作。
- 同步路径、刷新、添加和更多操作按钮增加名称并调整图标。
- 文件列表复选框固定在左侧,列宽支持独立拖动。
- 文件列表支持点击高亮与 `Shift` 连续范围多选,范围内项目同步勾选并可直接批量操作。
- 文件列表仅在内容确实溢出时显示滚动条,并支持触摸板纵向和横向滚动。
- 文件右键菜单和其他下拉菜单根据文字长度自适应宽度。
- 远程文件编辑改为应用内编辑,支持按扩展名进行语法高亮、2 MB 文件限制和原权限保留。
- CPU、内存和网络模块仅在已连接 SSH 标签页中提供远程详情入口。
- 监控显示位置设置统一应用于本地和 SSH 标签页;SSH 选择底部时,远程监控显示在文件面板下方。
- 进程表头与 CPU 或内存数据列保持对齐,复制操作位于终止操作之前。
- 统一按钮、复选框、删除、连接、执行和复制控件的鼠标指针反馈。
- 统一连接列表和历史命令列表的复选框对齐、行间距、操作区间距及选择数量显示。
@@ -77,6 +93,7 @@
### 修复
- 修复点击“新建分组”时程序崩溃的问题。
- 修复本地终端位于主目录时状态圆点越出标签栏的问题。
- 修复本地目录变化后标签仍显示旧路径或错误项目路径的问题。
- 修复管道、重定向等复合命令只记录最后一段内容的问题。
@@ -84,6 +101,8 @@
- 修复 Vim、`crontab -e` 等全屏编辑状态下的内容被错误记录为历史命令的问题。
- 修复历史命令重复、无故丢失或配置并发保存时被旧数据覆盖的问题。
- 修复连接列表和历史命令列表的滚动条遮挡右侧操作按钮的问题。
- 修复 SSH 终端滚动条遮挡最右侧文字的问题。
- 修复展开系统进程详情后列表行宽不一致的问题。
- 修复远程进程详情重复显示命令内容的问题。
- 修复空目录展开后目录图标缩进漂移、无法重新折叠的问题。
- 修复异步目录响应错误覆盖当前远程路径的问题。
@@ -97,6 +116,14 @@
- 修复系统进程和网络端口筛选框未随显示语言更新中英文占位文案的问题。
- 修复多个交互元素缺少指针光标及下拉菜单宽度过大的问题。
- 修复 macOS 构建中 Windows 专用窗口句柄参数产生未使用变量警告的问题。
- 修复窗口尺寸变化以及本地终端最大化后恢复时,终端网格重复重排并产生空行的问题。
- 修复窗口布局切换期间终端接收单列临时尺寸并导致界面卡死的问题。
- 修复 SSH 连接后工作区横向收缩,命令窗口内容只能显示在狭窄区域的问题。
- 修复本地终端链接识别错误保留末尾不成对括号的问题,同时保留链接内合法的成对括号。
- 修复终端关键词高亮规则重叠时同一单词出现多种颜色的问题。
- 修复终端关键词误匹配更长单词子串的问题,仅高亮完整单词或短语。
- 修复标签列表菜单无法拖动调整标签分组顺序的问题,并同步更新标签栏与已保存的标签状态。
- 修复标签列表选择标签时标签栏定位不准、末尾标签无法完整显示以及手动滚动被回弹的问题。
### 兼容性与稳定性
+31 -7
View File
@@ -1,4 +1,3 @@
remote_files: "REMOTE FILES"
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
sftp_connecting: "sftp connecting..."
refresh: "Refresh"
@@ -89,13 +88,13 @@ use_light_mode: "Use Light Mode"
use_dark_mode: "Use Dark Mode"
english: "English"
chinese: "简体中文"
idle_no_session: "idle / No session"
cpu: "CPU"
mem: "MEM"
swap: "SWAP"
net: "NET"
disk: "DISK"
live: "Live"
system_info: "SYSTEM"
memory: "Memory"
cpu_percent: "CPU"
memory_usage_short: "Memory"
@@ -153,7 +152,6 @@ no_matching_commands: "No matching commands"
copy_command: "Copy command"
execute_command: "Execute command"
close_command_history: "Close command history"
close_connections: "Close connections"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
@@ -173,7 +171,7 @@ cursor_style_default: "Default"
cursor_style_blink: "Blink"
cursor_style_beam: "Line"
cursor_style_beam_blink: "Line Blink"
monitoring_position: "Local Monitoring Position"
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
position_hidden: "Hidden"
@@ -231,6 +229,8 @@ settings_paste: "Paste Clipboard"
edit_file: "Edit File"
edit_file_tooltip: "Edit directly in ashell and save changes to the remote server."
terminal_encoding: "Terminal Encoding"
local_terminal_encoding: "Local Terminal Encoding"
local_terminal_encoding_changed: "Local terminal encoding changed to %{encoding}."
file_encoding: "File Encoding"
terminal_encoding_changed: "Terminal encoding changed to %{encoding}; new input and output will use it."
file_encoding_changed: "File encoding changed to %{encoding}."
@@ -249,6 +249,31 @@ sync_cwd: "Sync Path"
scroll: "Scroll ↓"
clone: "Clone"
about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project."
about_description: "A GPUI Component based SSH and local terminal client"
version: "Version"
close: "Close"
menu_about_ashell: "About Ashell"
menu_services: "Services"
menu_hide_ashell: "Hide Ashell"
menu_hide_others: "Hide Others"
menu_show_all: "Show All"
menu_quit_ashell: "Quit Ashell"
menu_file: "File"
menu_edit: "Edit"
menu_view: "View"
menu_help: "Help"
menu_close_window: "Close Window"
menu_undo: "Undo"
menu_redo: "Redo"
menu_cut: "Cut"
menu_copy: "Copy"
menu_paste: "Paste"
menu_select_all: "Select All"
menu_toggle_sidebar: "Toggle Sidebar"
menu_toggle_full_screen: "Enter Full Screen"
menu_minimize: "Minimize"
menu_zoom: "Zoom"
menu_project_website: "Ashell Project Website"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
@@ -266,13 +291,12 @@ transfers_limit: " (Max 100 records)"
press_new_key: "Press new key..."
none: "None"
settings_open_settings: "Open Settings"
settings_open_settings: "Settings"
settings_open_session: "Open Session"
settings_tab_list: "Tab List"
settings_open_transfers: "Open Transfer History"
panel_minimize: "Minimize Panel"
panel_expand: "Expand Panel"
panel_minimize_short: "Minimize"
panel_expand_short: "Expand"
settings_new_connection: "New Connection"
settings_open_search: "Search"
settings_quit_application: "Quit Application"
+32 -8
View File
@@ -1,4 +1,3 @@
remote_files: "远程文件"
open_ssh_tab_sftp: "打开 SSH 标签页以浏览 SFTP 文件。"
sftp_connecting: "正在连接 SFTP..."
refresh: "刷新"
@@ -89,13 +88,13 @@ use_light_mode: "使用浅色模式"
use_dark_mode: "使用深色模式"
english: "English"
chinese: "简体中文"
idle_no_session: "空闲 / 未连接会话"
cpu: "CPU"
mem: "内存"
swap: "虚拟内存"
net: "网络"
disk: "磁盘"
live: "实时"
system_info: "系统信息"
memory: "内存"
cpu_percent: "CPU"
memory_usage_short: "内存"
@@ -146,14 +145,13 @@ connections_deleted: "已删除 %{count} 个连接"
delete_selected_commands: "删除选中命令"
commands_deleted: "已删除 %{count} 条命令"
command_history: "历史命令"
command_history_short: "历史命令"
command_history_short: "历史"
command_history_empty: "暂无历史命令"
filter_command_history: "筛选历史命令"
no_matching_commands: "没有匹配的命令"
copy_command: "复制命令"
execute_command: "执行命令"
close_command_history: "关闭历史命令"
close_connections: "关闭连接列表"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
@@ -173,7 +171,7 @@ cursor_style_default: "默认"
cursor_style_blink: "闪烁"
cursor_style_beam: "细线"
cursor_style_beam_blink: "细线闪烁"
monitoring_position: "本机监控显示位置"
monitoring_position: "监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
position_hidden: "不显示"
@@ -231,6 +229,8 @@ settings_paste: "粘贴剪贴板"
edit_file: "编辑文件"
edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服务器。"
terminal_encoding: "终端编码"
local_terminal_encoding: "本地终端编码"
local_terminal_encoding_changed: "本地终端编码已切换为 %{encoding}。"
file_encoding: "文件编码"
terminal_encoding_changed: "终端编码已切换为 %{encoding},新的输入和输出将使用此编码。"
file_encoding_changed: "文件编码已切换为 %{encoding}。"
@@ -249,6 +249,31 @@ sync_cwd: "同步路径"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
about_description: "基于 GPUI Component 的 SSH 与本地终端客户端"
version: "版本"
close: "关闭"
menu_about_ashell: "关于 Ashell"
menu_services: "服务"
menu_hide_ashell: "隐藏 Ashell"
menu_hide_others: "隐藏其它应用"
menu_show_all: "全部显示"
menu_quit_ashell: "退出 Ashell"
menu_file: "文件"
menu_edit: "编辑"
menu_view: "视图"
menu_help: "帮助"
menu_close_window: "关闭窗口"
menu_undo: "撤销"
menu_redo: "重做"
menu_cut: "剪切"
menu_copy: "复制"
menu_paste: "粘贴"
menu_select_all: "全选"
menu_toggle_sidebar: "切换侧边栏"
menu_toggle_full_screen: "进入全屏"
menu_minimize: "最小化"
menu_zoom: "缩放"
menu_project_website: "Ashell 项目主页"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
@@ -266,13 +291,12 @@ transfers_limit: " (最多保留 100 条)"
press_new_key: "请按下新的快捷键..."
none: "无"
settings_open_settings: "打开设置"
settings_open_settings: "设置"
settings_open_session: "打开会话"
settings_tab_list: "标签列表"
settings_open_transfers: "打开传输历史"
panel_minimize: "折叠面板"
panel_expand: "展开面板"
panel_minimize_short: "折叠"
panel_expand_short: "展开"
settings_new_connection: "新建连接"
settings_open_search: "搜索"
settings_quit_application: "退出应用"
+9 -2
View File
@@ -9,6 +9,13 @@ CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
APP_VERSION="${APP_VERSION:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)}"
APP_BUILD_VERSION="${APP_BUILD_VERSION:-$APP_VERSION}"
if [[ -z "$APP_VERSION" ]]; then
echo "error: unable to read the package version from Cargo.toml" >&2
exit 1
fi
cd "$ROOT_DIR"
cargo build --release
@@ -40,9 +47,9 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$APP_VERSION</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$APP_BUILD_VERSION</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
-1
View File
@@ -1,7 +1,6 @@
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 TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0;
#[allow(dead_code)]
+127 -1
View File
@@ -1750,7 +1750,6 @@ impl Ashell {
if self.active_dialog.is_some() {
return;
}
self.show_collapsed_connections = false;
let editing_group = group.clone();
let is_editing = editing_group.is_some();
let existing_groups = self.config.connection_groups();
@@ -3229,6 +3228,99 @@ impl Ashell {
});
}
pub(crate) fn show_about_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() {
return;
}
self.active_dialog = Some(crate::app::DialogKind::About);
let view = cx.entity();
let version = env!("CARGO_PKG_VERSION");
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
dialog
.title(t!("menu_about_ashell").to_string())
.w(px(440.))
.keyboard(false)
.on_close({
let view = view.clone();
move |_, _, cx| {
view.update(cx, |this, cx| {
if this.active_dialog == Some(crate::app::DialogKind::About) {
this.active_dialog = None;
}
cx.notify();
});
}
})
.content(move |content, _window, cx| {
content.child(
v_flex()
.w_full()
.items_center()
.gap_3()
.py_4()
.child(
div()
.text_size(rems(1.5))
.font_weight(FontWeight::BOLD)
.child("Ashell"),
)
.child(div().text_size(rems(0.9)).child(format!(
"{} {}",
t!("version"),
version
)))
.child(
div()
.text_size(rems(0.9))
.text_color(cx.theme().muted_foreground)
.text_center()
.child(t!("about_description")),
)
.child(
div()
.text_size(rems(0.9))
.text_color(cx.theme().muted_foreground)
.text_center()
.child(t!("about_feedback_hint")),
)
.child(
pointer_button("about-project-link")
.label("https://github.com/rust-kotlin/ashell")
.ghost()
.on_click(|_, _, _| {
if let Err(error) =
open::that("https://github.com/rust-kotlin/ashell")
{
tracing::warn!(
"failed to open Ashell project website: {error}"
);
}
}),
),
)
})
.footer({
let view = view.clone();
h_flex().w_full().justify_end().child(
pointer_button("about-close")
.primary()
.label(t!("close").to_string())
.on_click(move |_, window, cx| {
view.update(cx, |this, cx| {
if this.active_dialog == Some(crate::app::DialogKind::About) {
this.active_dialog = None;
}
cx.notify();
});
window.close_dialog(cx);
}),
)
})
});
}
pub(crate) fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_dialog.is_some() {
return;
@@ -3259,6 +3351,7 @@ impl Ashell {
cx,
&this.config,
);
crate::app::system_menu::set_app_menus(cx);
cx.notify();
});
}
@@ -3702,6 +3795,39 @@ impl Ashell {
})
)
)
.item(
SettingItem::new(
t!("local_terminal_encoding").to_string(),
SettingField::render({
let view = view_clone_for_general.clone();
move |_, _window, cx| {
let current = view.read(cx).config.local_terminal_encoding();
pointer_button("local-terminal-encoding-dropdown")
.small()
.icon(IconName::Globe)
.label(current.label())
.dropdown_menu_with_anchor(Anchor::BottomRight, {
let view = view.clone();
move |mut menu, window, cx| {
let current = view.read(cx).config.local_terminal_encoding();
menu = menu.min_w(0.);
for encoding in TERMINAL_ENCODINGS.iter().copied() {
menu = menu.item(
PopupMenuItem::new(encoding.label())
.checked(encoding == current)
.on_click(window.listener_for(&view, move |this, _, _, cx| {
this.change_local_terminal_encoding(encoding, cx);
})),
);
}
menu
}
})
.into_any_element()
}
})
)
)
.item(
SettingItem::new(
t!("cursor_style").to_string(),
+10 -8
View File
@@ -6,6 +6,7 @@ pub mod keybinding_recorder;
pub mod resizable;
pub mod search;
pub mod startup;
pub mod system_menu;
pub mod theme;
pub mod ui;
@@ -213,6 +214,7 @@ impl ScrollbarHandle for TerminalScrollbarHandle {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DialogKind {
About,
Settings,
SessionSelector,
ConnectionExport,
@@ -312,6 +314,8 @@ pub(crate) struct Ashell {
pub(crate) process_scroll_handle: gpui::ScrollHandle,
pub(crate) port_scroll_handle: UniformListScrollHandle,
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
pub(crate) tabs_viewport_bounds: Option<Bounds<Pixels>>,
pub(crate) tab_scroll_animation_id: u64,
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
pub(crate) command_history_scroll_handle: gpui::ScrollHandle,
@@ -331,7 +335,6 @@ pub(crate) struct Ashell {
pub(crate) transfers: Vec<crate::terminal::Transfer>,
pub(crate) show_transfers_dialog: bool,
pub(crate) show_command_history: bool,
pub(crate) show_collapsed_connections: bool,
pub(crate) ssh_command_buffers: HashMap<String, String>,
pub(crate) ssh_command_starts: HashMap<String, (usize, usize)>,
pub(crate) system_status: Option<SharedString>,
@@ -354,8 +357,6 @@ pub(crate) struct Ashell {
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) collapsed_saved_sessions_overflowing: bool,
pub(crate) window_active: bool,
pub(crate) native_window_handle: Option<isize>,
pub(crate) unread_terminal_notifications: HashSet<String>,
@@ -395,7 +396,8 @@ pub(crate) struct Ashell {
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
pub(crate) last_window_size: Option<gpui::Size<Pixels>>,
pub(crate) last_sidebar_width: Option<Pixels>,
pub(crate) should_move_window: bool,
pub(crate) pending_local_terminal_resizes: HashMap<String, (u16, u16)>,
pub(crate) local_terminal_resize_task: Option<gpui::Task<()>>,
pub(crate) hovered_url: Option<HoveredUrl>,
pub(crate) cmd_ctrl_pressed: bool,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
@@ -826,6 +828,8 @@ impl Ashell {
process_scroll_handle: gpui::ScrollHandle::new(),
port_scroll_handle: UniformListScrollHandle::new(),
tabs_scroll_handle: gpui::ScrollHandle::new(),
tabs_viewport_bounds: None,
tab_scroll_animation_id: 0,
selector_scroll_handle: gpui::ScrollHandle::new(),
saved_scroll_handle: gpui::ScrollHandle::new(),
command_history_scroll_handle: gpui::ScrollHandle::new(),
@@ -858,7 +862,6 @@ impl Ashell {
},
show_transfers_dialog: false,
show_command_history: false,
show_collapsed_connections: false,
ssh_command_buffers: HashMap::new(),
ssh_command_starts: HashMap::new(),
system_status: None,
@@ -878,8 +881,6 @@ impl Ashell {
drag_split_origin: None,
sftp_panel_minimized: config.sftp_panel_minimized(),
sidebar_collapsed: config.sidebar_collapsed(),
collapsed_saved_scroll_handle: gpui::ScrollHandle::new(),
collapsed_saved_sessions_overflowing: false,
window_active: window.is_window_active(),
native_window_handle: crate::desktop_notification::native_window_handle(window),
unread_terminal_notifications: HashSet::new(),
@@ -916,7 +917,8 @@ impl Ashell {
events_tx,
last_window_size: None,
last_sidebar_width,
should_move_window: false,
pending_local_terminal_resizes: HashMap::new(),
local_terminal_resize_task: None,
hovered_url: None,
cmd_ctrl_pressed: false,
_subscriptions,
-1
View File
@@ -326,7 +326,6 @@ impl Ashell {
pointer_button("search-btn")
.ghost()
.small()
.rounded(px(999.))
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
+1
View File
@@ -343,6 +343,7 @@ pub(crate) fn open_main_window(cx: &mut App) {
window.set_window_title("ashell");
gpui_component::Theme::sync_system_appearance(Some(window), cx);
let view = cx.new(|cx| Ashell::new(window, cx));
crate::app::system_menu::set_app_menus(cx);
tracing::info!("[ui] main application window opened");
let focus_handle = view.read(cx).focus_handle.clone();
+132
View File
@@ -0,0 +1,132 @@
#[cfg(target_os = "macos")]
use gpui::KeyBinding;
use gpui::{App, Menu, MenuItem};
use rust_i18n::t;
gpui::actions!(
ashell_system_menu,
[
AboutAshell,
CloseWindow,
HideAshell,
HideOtherApplications,
MinimizeWindow,
NewLocalTerminal,
OpenProjectWebsite,
ShowAllApplications,
ToggleFullScreen,
ZoomWindow
]
);
const PROJECT_URL: &str = "https://github.com/rust-kotlin/ashell";
pub(crate) fn init(cx: &mut App) {
#[cfg(target_os = "macos")]
{
cx.on_action(|_: &HideAshell, cx| cx.hide());
cx.on_action(|_: &HideOtherApplications, cx| cx.hide_other_apps());
cx.on_action(|_: &ShowAllApplications, cx| cx.unhide_other_apps());
cx.bind_keys([
KeyBinding::new("cmd-h", HideAshell, None),
KeyBinding::new("alt-cmd-h", HideOtherApplications, None),
KeyBinding::new("cmd-m", MinimizeWindow, None),
KeyBinding::new("ctrl-cmd-f", ToggleFullScreen, None),
]);
}
cx.on_action(|_: &OpenProjectWebsite, _| {
if let Err(error) = open::that(PROJECT_URL) {
tracing::warn!("failed to open Ashell project website: {error}");
}
});
}
pub(crate) fn set_app_menus(cx: &mut App) {
let mut application_items = vec![
MenuItem::action(t!("menu_about_ashell").to_string(), AboutAshell),
MenuItem::separator(),
MenuItem::action(t!("settings").to_string(), crate::OpenSettings),
];
#[cfg(target_os = "macos")]
application_items.extend([
MenuItem::separator(),
MenuItem::os_submenu(
t!("menu_services").to_string(),
gpui::SystemMenuType::Services,
),
MenuItem::separator(),
MenuItem::action(t!("menu_hide_ashell").to_string(), HideAshell),
MenuItem::action(t!("menu_hide_others").to_string(), HideOtherApplications),
MenuItem::action(t!("menu_show_all").to_string(), ShowAllApplications),
]);
application_items.extend([
MenuItem::separator(),
MenuItem::action(t!("menu_quit_ashell").to_string(), crate::QuitApplication),
]);
cx.set_menus([
Menu::new("Ashell").items(application_items),
Menu::new(t!("menu_file").to_string()).items([
MenuItem::action(t!("local_terminal").to_string(), NewLocalTerminal),
MenuItem::action(t!("new_connection").to_string(), crate::NewSsh),
MenuItem::separator(),
MenuItem::action(t!("menu_close_window").to_string(), CloseWindow),
]),
Menu::new(t!("menu_edit").to_string()).items([
MenuItem::os_action(
t!("menu_undo").to_string(),
gpui_component::input::Undo,
gpui::OsAction::Undo,
),
MenuItem::os_action(
t!("menu_redo").to_string(),
gpui_component::input::Redo,
gpui::OsAction::Redo,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_cut").to_string(),
gpui_component::input::Cut,
gpui::OsAction::Cut,
),
MenuItem::os_action(
t!("menu_copy").to_string(),
crate::Copy,
gpui::OsAction::Copy,
),
MenuItem::os_action(
t!("menu_paste").to_string(),
crate::Paste,
gpui::OsAction::Paste,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_select_all").to_string(),
gpui_component::input::SelectAll,
gpui::OsAction::SelectAll,
),
]),
Menu::new(t!("menu_view").to_string()).items([
MenuItem::action(t!("search").to_string(), crate::OpenSearch),
MenuItem::action(t!("menu_toggle_sidebar").to_string(), crate::ToggleSidebar),
MenuItem::action(
t!("settings_open_transfers").to_string(),
crate::OpenTransfers,
),
MenuItem::separator(),
MenuItem::action(t!("menu_toggle_full_screen").to_string(), ToggleFullScreen),
]),
// GPUI recognizes this exact title as the native macOS Window menu.
Menu::new("Window").items([
MenuItem::action(t!("menu_minimize").to_string(), MinimizeWindow),
MenuItem::action(t!("menu_zoom").to_string(), ZoomWindow),
]),
Menu::new(t!("menu_help").to_string()).items([MenuItem::action(
t!("menu_project_website").to_string(),
OpenProjectWebsite,
)]),
]);
}
+1
View File
@@ -127,6 +127,7 @@ impl Ashell {
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
crate::app::system_menu::set_app_menus(cx);
self.connection_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_connections").to_string(), window, cx);
});
+745 -725
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -22,6 +22,9 @@ pub(crate) use app::keybinding_recorder::{
OpenSession, OpenSettings, OpenTransfers, Paste, QuitApplication, SplitPaneDown, SplitPaneLeft,
SplitPaneRight, SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::system_menu::{
AboutAshell, CloseWindow, MinimizeWindow, NewLocalTerminal, ToggleFullScreen, ZoomWindow,
};
pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup};
@@ -45,6 +48,7 @@ fn main() {
});
app.run(move |cx| {
gpui_component::init(cx);
app::system_menu::init(cx);
cx.on_action(|_: &QuitApplication, cx| cx.quit());
cx.bind_keys([
+15
View File
@@ -271,6 +271,8 @@ pub struct ConfigFile {
pub locale: String,
#[serde(default = "default_terminal_font_size")]
pub terminal_font_size: f32,
#[serde(default)]
pub local_terminal_encoding: TextEncoding,
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
#[serde(default)]
@@ -446,6 +448,7 @@ impl Default for ConfigFile {
dark_theme_name: String::new(),
locale: default_locale(),
terminal_font_size: default_terminal_font_size(),
local_terminal_encoding: TextEncoding::Utf8,
ui_font_size: default_ui_font_size(),
right_click_copy_paste: false,
keyword_highlight: false,
@@ -1249,6 +1252,14 @@ impl ConfigStore {
self.cache.terminal_font_size = terminal_font_size.max(10.0);
}
pub fn local_terminal_encoding(&self) -> TextEncoding {
self.cache.local_terminal_encoding
}
pub fn set_local_terminal_encoding(&mut self, encoding: TextEncoding) {
self.cache.local_terminal_encoding = encoding;
}
pub fn ui_font_size(&self) -> f32 {
if self.cache.ui_font_size <= 0.0 {
default_ui_font_size()
@@ -1468,6 +1479,7 @@ impl ConfigStore {
disk_config.dark_theme_name = local_config.dark_theme_name;
disk_config.locale = local_config.locale;
disk_config.terminal_font_size = local_config.terminal_font_size;
disk_config.local_terminal_encoding = local_config.local_terminal_encoding;
disk_config.ui_font_size = local_config.ui_font_size;
disk_config.right_click_copy_paste = local_config.right_click_copy_paste;
disk_config.keyword_highlight = local_config.keyword_highlight;
@@ -1876,6 +1888,7 @@ mod tests {
assert!(config.sftp_file_columns.is_none());
assert!(!config.sftp_file_columns_customized);
assert!(config.connection_groups.is_empty());
assert_eq!(config.local_terminal_encoding, TextEncoding::Utf8);
assert!(config.collapsed_connection_groups.is_empty());
}
@@ -2020,6 +2033,7 @@ mod tests {
let mut local_config = ConfigFile {
ui_font_size: 18.0,
terminal_font_size: 20.0,
local_terminal_encoding: TextEncoding::Gbk,
show_hidden_files: true,
sftp_file_columns_customized: true,
remember_tabs: true,
@@ -2057,6 +2071,7 @@ mod tests {
assert_eq!(decrypted.ui_font_size, 18.0);
assert_eq!(decrypted.terminal_font_size, 20.0);
assert_eq!(decrypted.local_terminal_encoding, TextEncoding::Gbk);
assert!(decrypted.show_hidden_files);
assert!(decrypted.sftp_file_columns_customized);
assert!(decrypted.remember_tabs);
+127 -11
View File
@@ -9,6 +9,7 @@ use gpui::{
};
use gpui_component::{Theme, WindowExt as _, input::InputState};
use rust_i18n::t;
use std::time::Duration;
use uuid::Uuid;
use self::config::{
@@ -99,6 +100,7 @@ fn connecting_sftp_state() -> crate::terminal::SftpUiState {
loading_directories: std::collections::HashSet::new(),
directory_errors: std::collections::HashMap::new(),
selected_path: None,
selection_anchor: None,
preview: None,
selected_entries: std::collections::HashSet::new(),
home_dir: "/".into(),
@@ -455,6 +457,66 @@ impl Ashell {
cx.notify();
}
pub(crate) fn resize_terminal_for_layout(
&mut self,
tab_id: &str,
cols: u16,
rows: u16,
cx: &mut Context<Self>,
) -> Option<RenderSnapshot> {
let tab_index = self.tabs.iter().position(|tab| tab.id == tab_id)?;
let cols = cols.max(1);
let rows = rows.max(1);
if self.tabs[tab_index].kind != TabKind::Local {
let keyword_highlight = self.config.keyword_highlight();
if self.tabs[tab_index].resize(cols, rows) {
return Some(self.tabs[tab_index].render_snapshot(keyword_highlight));
}
return None;
}
if self.tabs[tab_index].cols == cols && self.tabs[tab_index].rows == rows {
self.pending_local_terminal_resizes.remove(tab_id);
if self.pending_local_terminal_resizes.is_empty() {
self.local_terminal_resize_task = None;
}
return None;
}
let requested_size = (cols, rows);
if self.pending_local_terminal_resizes.get(tab_id) == Some(&requested_size) {
return None;
}
self.pending_local_terminal_resizes
.insert(tab_id.to_string(), requested_size);
self.local_terminal_resize_task = Some(cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(80))
.await;
let _ = this.update(cx, |this, cx| {
let pending = std::mem::take(&mut this.pending_local_terminal_resizes);
this.local_terminal_resize_task = None;
let mut resized = false;
for (tab_id, (cols, rows)) in pending {
if let Some(tab) = this
.tabs
.iter_mut()
.find(|tab| tab.id == tab_id && tab.kind == TabKind::Local)
{
resized |= tab.resize(cols, rows);
}
}
if resized {
cx.notify();
}
});
}));
None
}
pub(crate) fn open_local(&mut self, cx: &mut Context<Self>) {
let previous_active_tab = self.active_tab.clone();
let id = Uuid::new_v4().to_string();
@@ -473,6 +535,7 @@ impl Ashell {
let mut tab =
TerminalTab::new_local(id.clone(), title.clone(), backend, backend_events);
tab.local_cwd = initial_directory;
tab.set_text_encoding(self.config.local_terminal_encoding());
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
self.tabs.push(tab);
self.active_tab = Some(id.clone());
@@ -789,7 +852,6 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.show_collapsed_connections = false;
let Some(session) = self.config.get(&session_id).cloned() else {
self.status = "saved session not found".into();
cx.notify();
@@ -805,7 +867,6 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.show_collapsed_connections = false;
let Some(session) = self.config.get(&session_id).cloned() else {
self.status = "saved session not found".into();
cx.notify();
@@ -960,6 +1021,36 @@ impl Ashell {
cx.notify();
}
pub(crate) fn change_local_terminal_encoding(
&mut self,
encoding: TextEncoding,
cx: &mut Context<Self>,
) {
if self.config.local_terminal_encoding() == encoding {
return;
}
self.config.set_local_terminal_encoding(encoding);
for tab in self
.tabs
.iter_mut()
.filter(|tab| tab.kind == TabKind::Local)
{
tab.set_text_encoding(encoding);
}
if self.config.remember_tabs() {
self.capture_tabs_state();
}
self.save_preferences_background();
self.status = t!(
"local_terminal_encoding_changed",
encoding = encoding.label()
)
.to_string()
.into();
cx.notify();
}
pub(crate) fn refresh_ssh_config(&mut self) {
self.ssh_config_entries =
crate::session::ssh_config::parse_ssh_config().unwrap_or_default();
@@ -1010,7 +1101,6 @@ impl Ashell {
}
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
self.show_collapsed_connections = false;
tracing::info!(
"[ui] user clicked to connect saved session '{}'",
session_id
@@ -1846,14 +1936,6 @@ impl Ashell {
.map(|tab| tab.kind)
}
pub(crate) fn active_title(&self) -> String {
self.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
.map(|t| t.title.clone())
.unwrap_or_else(|| t!("idle_no_session").into())
}
pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> {
let active_id = self.active_tab.as_ref()?;
let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?;
@@ -2204,6 +2286,40 @@ impl Ashell {
cx.notify();
}
pub(crate) fn reorder_tab_groups(
&mut self,
dragged_group_id: &str,
target_group_id: &str,
cx: &mut Context<Self>,
) {
if dragged_group_id == target_group_id {
return;
}
let Some(from_index) = self
.tab_groups
.iter()
.position(|group| group.id == dragged_group_id)
else {
return;
};
let Some(mut target_index) = self
.tab_groups
.iter()
.position(|group| group.id == target_group_id)
else {
return;
};
let dragged_group = self.tab_groups.remove(from_index);
if from_index < target_index {
target_index -= 1;
}
self.tab_groups.insert(target_index, dragged_group);
self.save_tabs_state_background();
cx.notify();
}
pub(crate) fn sync_pane_root_to_group(&mut self) {
if let Some(group_id) = self.active_group.clone() {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
+41 -1
View File
@@ -46,6 +46,7 @@ impl Ashell {
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path.clone();
sftp.selected_path = None;
sftp.selection_anchor = None;
sftp.preview = None;
sftp.selected_entries.clear();
sftp.expand_to(&path);
@@ -134,13 +135,48 @@ impl Ashell {
}
}
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
pub(crate) fn select_sftp_entry(
&mut self,
entry: RemoteEntry,
visible_paths: &[String],
extend_range: bool,
cx: &mut Context<Self>,
) {
if extend_range {
if let Some(sftp) = self.active_sftp_mut() {
let target_path = entry.full_path.clone();
let target_index = visible_paths.iter().position(|path| path == &target_path);
let anchor_index = sftp
.selection_anchor
.as_ref()
.and_then(|anchor| visible_paths.iter().position(|path| path == anchor));
sftp.selected_entries.clear();
if let Some(target_index) = target_index {
let anchor_index = anchor_index.unwrap_or(target_index);
let start = anchor_index.min(target_index);
let end = anchor_index.max(target_index);
sftp.selected_entries
.extend(visible_paths[start..=end].iter().cloned());
} else {
sftp.selected_entries.insert(target_path.clone());
}
if sftp.selection_anchor.is_none() {
sftp.selection_anchor = Some(target_path.clone());
}
sftp.selected_path = Some(target_path);
}
cx.notify();
return;
}
if entry.is_dir {
self.navigate_sftp(entry.full_path, cx);
return;
}
self.mark_sftp_entry_selected(&entry.full_path, cx);
if let Some(sftp) = self.active_sftp_mut() {
sftp.selection_anchor = Some(entry.full_path.clone());
if !sftp.selected_entries.remove(&entry.full_path) {
sftp.selected_entries.insert(entry.full_path);
}
@@ -381,6 +417,8 @@ impl Ashell {
cx: &mut Context<Self>,
) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.selected_path = Some(path.clone());
sftp.selection_anchor = Some(path.clone());
if checked {
sftp.selected_entries.insert(path);
} else {
@@ -403,6 +441,8 @@ impl Ashell {
}
} else {
sftp.selected_entries.clear();
sftp.selected_path = None;
sftp.selection_anchor = None;
}
cx.notify();
}
+16 -22
View File
@@ -614,32 +614,26 @@ impl Element for TerminalElement {
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
// Save the precise GPUI-rendered bounds of this terminal element.
// This is 100% accurate because it is recorded during layout prepaint.
// Local window animations emit many intermediate sizes. The view
// coalesces those requests before resizing its terminal grid and PTY.
let view = self.view.clone();
let tab_id = self.tab_id.clone();
view.update(cx, |this, cx| {
let old_bounds = this.terminal_bounds.insert(tab_id.clone(), bounds);
// Sync PTY size unconditionally on every prepaint layout pass to ensure
// absolute synchronization with GPUI layout regardless of intermediate events.
let line_height = this.terminal_line_height();
let cell_width = this.terminal_cell_width();
let w = bounds.size.width.as_f32();
let h = bounds.size.height.as_f32();
let cols = (w / cell_width).floor().max(1.0) as u16;
let rows = (h / line_height).floor().max(1.0) as u16;
if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.resize(cols, rows);
}
if old_bounds != Some(bounds) {
cx.notify();
}
let cols = (bounds.size.width.as_f32() / self.cell_width.as_f32())
.floor()
.max(1.0) as u16;
let rows = (bounds.size.height.as_f32() / self.line_height.as_f32())
.floor()
.max(1.0) as u16;
let resized_snapshot = view.update(cx, |this, cx| {
this.terminal_bounds.insert(tab_id.clone(), bounds);
this.resize_terminal_for_layout(&tab_id, cols, rows, cx)
});
if let Some(snapshot) = resized_snapshot {
self.snapshot = snapshot;
}
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
PrepaintState {
bounds,
+6 -6
View File
@@ -19,11 +19,7 @@ use alacritty_terminal::{
grid::{Dimensions, Scroll},
index::{Column, Line, Point, Side},
selection::{Selection, SelectionRange, SelectionType},
term::{
Config, Term, TermMode,
cell::{Cell, Flags},
point_to_viewport, viewport_to_point,
},
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
vte::ansi::{CursorShape, Processor},
};
use base64::{Engine as _, engine::general_purpose};
@@ -891,6 +887,7 @@ pub struct SftpUiState {
pub loading_directories: HashSet<String>,
pub directory_errors: HashMap<String, String>,
pub selected_path: Option<String>,
pub selection_anchor: Option<String>,
pub preview: Option<PreviewData>,
pub selected_entries: HashSet<String>,
pub home_dir: String,
@@ -1626,7 +1623,7 @@ impl TerminalTab {
next
}
pub fn resize(&mut self, cols: u16, rows: u16) {
pub fn resize(&mut self, cols: u16, rows: u16) -> bool {
let new_cols = cols.max(1);
let new_rows = rows.max(1);
if self.cols != new_cols || self.rows != new_rows {
@@ -1639,6 +1636,9 @@ impl TerminalTab {
);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.send_backend(BackendCommand::Resize { cols, rows });
true
} else {
false
}
}