fix: 完善终端拖拽、Windows 细节与细节修正 (#112)

This commit is contained in:
Realm
2026-08-27 00:01:03 +08:00
committed by Tom Cruise
parent ee98a933e6
commit 168a20e157
19 changed files with 1582 additions and 428 deletions
+8
View File
@@ -45,6 +45,7 @@
- 工作区交互统一为下划线活动页签、平面连接列表和低干扰图标工具栏;标签过多时提供溢出菜单,
空连接列表可直接新建连接。
- 标签列表选择标签时,标签栏会以平滑动画定位到完整可见位置,同时保留手动滚动能力。
- 标签列表菜单条目新增删除按钮,可直接关闭指定标签页。
- SSH 监控区增加当前会话身份条,显示会话名称、连接地址和连接状态;断开时保留状态上下文,
但禁用远程资源详情入口。
- 侧边栏折叠后完全隐藏,不再保留折叠态连接图标或连接列表入口。
@@ -122,8 +123,15 @@
- 修复本地终端链接识别错误保留末尾不成对括号的问题,同时保留链接内合法的成对括号。
- 修复终端关键词高亮规则重叠时同一单词出现多种颜色的问题。
- 修复终端关键词误匹配更长单词子串的问题,仅高亮完整单词或短语。
- 修复连接列表无法连接仅提供旧版 SSH 密钥交换算法的 OpenSSH 服务器的问题。
- 修复 CentOS 6 等旧版 Linux 内核缺少 `MemAvailable` 字段时内存用量显示为总内存的问题。
- 修复 CentOS 6 等旧版 Linux 的 CPU、网络、磁盘和端口探测在旧命令输出或计数器异常时显示不准确的问题。
- 以 SSH 文件列表面板头部按钮为基准,统一应用按钮的高度、字号和图标尺寸;界面默认字体调整为 14px、终端默认字体调整为 12px,改善整体可读性与操作一致性。
- 修复标签列表菜单无法拖动调整标签分组顺序的问题,并同步更新标签栏与已保存的标签状态。
- 修复标签列表选择标签时标签栏定位不准、末尾标签无法完整显示以及手动滚动被回弹的问题。
- 修复 Windows 融合标题栏无法通过鼠标拖动窗口的问题。
- 修复新建 SSH 标签页后因布局时序或分屏数量导致标签按钮未准确滚动到完整可见位置的问题。
- 为 SSH 文件列表面板的展开/折叠按钮补充明确文字,并随面板状态同步切换。
### 兼容性与稳定性
+1
View File
@@ -66,6 +66,7 @@ raw-window-handle = "0.6"
windows = { version = "0.57", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
+11
View File
@@ -231,6 +231,14 @@ edit_file_tooltip: "Edit directly in ashell and save changes to the remote serve
terminal_encoding: "Terminal Encoding"
local_terminal_encoding: "Local Terminal Encoding"
local_terminal_encoding_changed: "Local terminal encoding changed to %{encoding}."
default_local_terminal: "Default Local Terminal"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "Command Prompt"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "Not installed"
local_terminal_shell_fallback: "The selected local terminal is unavailable; switched back to %{terminal}."
local_terminal_shell_changed: "Default local terminal changed to %{terminal}; new terminals will use it."
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}."
@@ -280,6 +288,7 @@ settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_terminal: "Terminal"
settings_group_other: "Other"
settings_group_keybind_general: "General"
settings_group_keybind_zoom: "Zoom"
@@ -296,7 +305,9 @@ settings_open_session: "Open Session"
settings_tab_list: "Tab List"
settings_open_transfers: "Open Transfer History"
panel_minimize: "Minimize Panel"
panel_minimize_short: "Collapse"
panel_expand: "Expand Panel"
panel_expand_short: "Expand"
settings_new_connection: "New Connection"
settings_open_search: "Search"
settings_quit_application: "Quit Application"
+11
View File
@@ -231,6 +231,14 @@ edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服
terminal_encoding: "终端编码"
local_terminal_encoding: "本地终端编码"
local_terminal_encoding_changed: "本地终端编码已切换为 %{encoding}。"
default_local_terminal: "默认本地终端"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "命令提示符"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "未安装"
local_terminal_shell_fallback: "所选本地终端不可用,已改回 %{terminal}。"
local_terminal_shell_changed: "默认本地终端已切换为 %{terminal},新终端生效。"
file_encoding: "文件编码"
terminal_encoding_changed: "终端编码已切换为 %{encoding},新的输入和输出将使用此编码。"
file_encoding_changed: "文件编码已切换为 %{encoding}。"
@@ -280,6 +288,7 @@ settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_terminal: "终端"
settings_group_other: "其它"
settings_group_keybind_general: "常规"
settings_group_keybind_zoom: "缩放"
@@ -296,7 +305,9 @@ settings_open_session: "打开会话"
settings_tab_list: "标签列表"
settings_open_transfers: "打开传输历史"
panel_minimize: "折叠面板"
panel_minimize_short: "折叠"
panel_expand: "展开面板"
panel_expand_short: "展开"
settings_new_connection: "新建连接"
settings_open_search: "搜索"
settings_quit_application: "退出应用"
+14 -6
View File
@@ -2,8 +2,8 @@ use std::{rc::Rc, time::Duration};
use gpui::{
App, ClipboardItem, ElementId, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _,
Window, div, prelude::FluentBuilder as _, px,
ParentElement as _, Rems, RenderOnce, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, Size,
@@ -12,8 +12,16 @@ use gpui_component::{
switch::Switch,
};
/// Shared application button size, matching the SSH file panel header controls.
pub(crate) const APP_BUTTON_SIZE: Size = Size::Small;
pub(crate) fn pointer_button(id: impl Into<ElementId>) -> Button {
Button::new(id).cursor_pointer()
Button::new(id).with_size(APP_BUTTON_SIZE).cursor_pointer()
}
/// Keep intentionally compact text readable when a view uses relative sizes.
pub(crate) fn ui_rems(size: f32) -> Rems {
rems(size.max(0.85))
}
pub(crate) fn pointer_checkbox(id: impl Into<ElementId>) -> Checkbox {
@@ -106,7 +114,7 @@ impl RenderOnce for PointerSelectionCheckbox {
div()
.id(self.id)
.size(px(14.))
.size(px(16.))
.flex_none()
.flex()
.items_center()
@@ -140,7 +148,7 @@ impl RenderOnce for PointerSelectionCheckbox {
|this, icon| {
this.child(
Icon::new(icon)
.with_size(Size::XSmall)
.with_size(Size::Small)
.text_color(icon_color),
)
},
@@ -191,7 +199,7 @@ impl RenderOnce for PointerClipboard {
IconName::Copy
})
.ghost()
.xsmall()
.small()
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
.when(!copied, |this| {
let state = state.clone();
+115 -66
View File
@@ -2,7 +2,7 @@ use gpui::{
Anchor, AppContext as _, Bounds, Context, DragMoveEvent, ElementId, Empty, Entity,
Focusable as _, FontWeight, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent,
ParentElement as _, Pixels, Point, Render, SharedString, Size, StatefulInteractiveElement as _,
Styled as _, Window, div, point, prelude::FluentBuilder as _, px, rems, size,
Styled as _, Window, div, point, prelude::FluentBuilder as _, px, size,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, WindowExt as _,
@@ -19,7 +19,7 @@ use rust_i18n::t;
use crate::{
Ashell,
app::controls::{pointer_button, pointer_switch},
app::controls::{pointer_button, pointer_switch, ui_rems},
session::config::AuthMethod,
system::{RemoteProcess, format_bytes},
text_encoding::{FILE_ENCODINGS, TERMINAL_ENCODINGS, TextEncoding},
@@ -719,13 +719,13 @@ impl Ashell {
.gap_1()
.child(
div()
.text_size(rems(1.0))
.text_size(ui_rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.child(t!("local_terminal")),
)
.child(
div()
.text_size(rems(0.917))
.text_size(ui_rems(0.917))
.text_color(_cx.theme().muted_foreground)
.child(t!("open_local_shell_tab")),
),
@@ -763,13 +763,13 @@ impl Ashell {
.gap_1()
.child(
div()
.text_size(rems(1.0))
.text_size(ui_rems(1.0))
.font_weight(FontWeight::SEMIBOLD)
.child(t!("new_connection")),
)
.child(
div()
.text_size(rems(0.917))
.text_size(ui_rems(0.917))
.text_color(_cx.theme().muted_foreground)
.child(t!("create_or_edit_ssh_session")),
),
@@ -835,6 +835,7 @@ impl Ashell {
this.active_dialog = None;
this.connect_saved_session(
connect_id.clone(),
window,
cx,
);
window.close_dialog(cx);
@@ -847,7 +848,7 @@ impl Ashell {
.gap_1()
.child(
div()
.text_size(rems(1.0))
.text_size(ui_rems(1.0))
.font_weight(
FontWeight::SEMIBOLD,
)
@@ -855,7 +856,7 @@ impl Ashell {
)
.child(
div()
.text_size(rems(0.917))
.text_size(ui_rems(0.917))
.text_color(
_cx.theme()
.muted_foreground,
@@ -930,7 +931,6 @@ impl Ashell {
let clear_btn = if can_clear {
Some(
pointer_button("clear_transfers_btn")
.small()
.ghost()
.icon(IconName::Delete)
.label(t!("clear_transfers").to_string())
@@ -974,7 +974,6 @@ impl Ashell {
.child(
h_flex().gap_2().children(clear_btn).child(
pointer_button("close_dialog")
.small()
.ghost()
.icon(IconName::Close)
.on_click(window.listener_for(
@@ -1047,7 +1046,6 @@ impl Ashell {
format!("pause-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Pause)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1061,7 +1059,6 @@ impl Ashell {
format!("cancel-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1079,7 +1076,6 @@ impl Ashell {
format!("resume-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Play)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1093,7 +1089,6 @@ impl Ashell {
format!("cancel-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1111,7 +1106,6 @@ impl Ashell {
format!("remove-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1132,7 +1126,6 @@ impl Ashell {
format!("folder-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Folder)
.on_click({
let target = t.info.target.clone();
@@ -1148,7 +1141,6 @@ impl Ashell {
format!("remove-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1165,7 +1157,6 @@ impl Ashell {
format!("remove-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1181,7 +1172,6 @@ impl Ashell {
format!("remove-{}", t.info.id),
))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
@@ -1219,7 +1209,6 @@ impl Ashell {
)))
.icon(icon)
.ghost()
.small()
.disabled(true),
)
.child(
@@ -1428,7 +1417,7 @@ impl Ashell {
.gap_1()
.children(selected_paths.into_iter().map(|path| {
div()
.text_size(rems(0.917))
.text_size(ui_rems(0.917))
.text_color(cx.theme().muted_foreground)
.child(path)
})),
@@ -1448,7 +1437,7 @@ impl Ashell {
.gap_2()
.children(warning_block)
.child(
div().text_size(rems(1.0)).mb_2().child(
div().text_size(ui_rems(1.0)).mb_2().child(
t!(
"confirm_delete_desc",
count = view
@@ -1547,7 +1536,7 @@ impl Ashell {
.w_full()
.whitespace_normal()
.line_clamp(3)
.text_size(rems(0.917))
.text_size(ui_rems(0.917))
.child(
t!("confirm_terminate_process_desc", pid = pid)
.to_string(),
@@ -1564,13 +1553,13 @@ impl Ashell {
div()
.w_full()
.truncate()
.text_size(rems(0.833))
.text_size(ui_rems(0.833))
.font_weight(FontWeight::SEMIBOLD)
.child(process_name.clone()),
)
.child(
div()
.text_size(rems(0.75))
.text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground)
.child(
t!(
@@ -1687,7 +1676,7 @@ impl Ashell {
div()
.w_full()
.whitespace_normal()
.text_size(rems(0.917))
.text_size(ui_rems(0.917))
.child(
t!("reconnect_ssh_desc", name = session_name.as_str())
.to_string(),
@@ -2580,7 +2569,7 @@ impl Ashell {
)
.child(
pointer_button("sftp-editor-close")
.xsmall()
.small()
.ghost()
.icon(IconName::Close)
.tooltip(t!("cancel").to_string())
@@ -2645,7 +2634,7 @@ impl Ashell {
"sftp-editor-encoding",
)
.ghost()
.small()
.icon(IconName::Globe)
.label(file_encoding.label())
.tooltip(
@@ -3262,25 +3251,25 @@ impl Ashell {
.py_4()
.child(
div()
.text_size(rems(1.5))
.text_size(ui_rems(1.5))
.font_weight(FontWeight::BOLD)
.child("Ashell"),
)
.child(div().text_size(rems(0.9)).child(format!(
.child(div().text_size(ui_rems(0.9)).child(format!(
"{} {}",
t!("version"),
version
)))
.child(
div()
.text_size(rems(0.9))
.text_size(ui_rems(0.9))
.text_color(cx.theme().muted_foreground)
.text_center()
.child(t!("about_description")),
)
.child(
div()
.text_size(rems(0.9))
.text_size(ui_rems(0.9))
.text_color(cx.theme().muted_foreground)
.text_center()
.child(t!("about_feedback_hint")),
@@ -3377,6 +3366,65 @@ impl Ashell {
let focus_handle = view.read(cx).focus_handle.clone();
let terminal_group = cfg!(windows).then(|| {
use crate::session::config::LocalTerminalShell;
let shells = [
LocalTerminalShell::WindowsPowerShell,
LocalTerminalShell::PowerShell7,
LocalTerminalShell::CommandPrompt,
LocalTerminalShell::GitBash,
];
SettingGroup::new()
.title(t!("settings_group_terminal").to_string())
.item(SettingItem::new(
t!("default_local_terminal").to_string(),
SettingField::render({
let view = view_clone_for_general.clone();
move |_, _window, cx| {
let current = view.read(cx).config.local_terminal_shell();
pointer_button("default-local-terminal-dropdown")
.icon(IconName::SquareTerminal)
.label(crate::session::local_terminal_shell_label(current))
.dropdown_menu_with_anchor(Anchor::BottomRight, {
let view = view.clone();
move |mut menu, window, _cx| {
menu = menu.min_w(px(220.));
for shell in shells {
let checked = shell == current;
let available =
crate::backend::local::local_terminal_shell_available(shell);
let label =
crate::session::local_terminal_shell_label(shell);
let label = if available {
label
} else {
format!(
"{} ({})",
label,
t!("local_terminal_not_installed")
)
};
menu = menu.item(
PopupMenuItem::new(label)
.checked(checked)
.disabled(!available)
.on_click(window.listener_for(
&view,
move |this, _, _, cx| {
this.change_local_terminal_shell(shell, cx);
},
)),
);
}
menu
}
})
.into_any_element()
}
}),
))
});
content.child(
div()
.flex()
@@ -3483,7 +3531,7 @@ impl Ashell {
(state.follow_system_theme, state.theme_mode.is_dark())
};
pointer_button("theme-mode-dropdown")
.small()
.icon(if follow_system { IconName::Sun } else if is_dark_mode { IconName::Moon } else { IconName::Sun })
.label(if follow_system { t!("follow_system").to_string() } else if is_dark_mode { t!("use_dark_mode").to_string() } else { t!("use_light_mode").to_string() })
.dropdown_menu_with_anchor(Anchor::BottomRight, {
@@ -3531,7 +3579,7 @@ impl Ashell {
move |_, _window, cx| {
let current_theme = view.read(cx).light_theme_name.to_string();
pointer_button("light-theme-dropdown")
.small()
.icon(IconName::Sun)
.label(current_theme.clone())
.dropdown_menu_with_anchor(Anchor::BottomRight, {
@@ -3567,7 +3615,7 @@ impl Ashell {
move |_, _window, cx| {
let current_theme = view.read(cx).dark_theme_name.to_string();
pointer_button("dark-theme-dropdown")
.small()
.icon(IconName::Moon)
.label(current_theme.clone())
.dropdown_menu_with_anchor(Anchor::BottomRight, {
@@ -3603,7 +3651,7 @@ impl Ashell {
move |_, _window, cx| {
let current_style = view.read(cx).config.title_bar_style();
pointer_button("title-bar-style-dropdown")
.small()
.label(match current_style {
crate::session::config::TitleBarStyle::Native => t!("title_bar_native").to_string(),
crate::session::config::TitleBarStyle::Integrated => t!("title_bar_integrated").to_string(),
@@ -3640,6 +3688,7 @@ impl Ashell {
)
)
)
.groups(terminal_group)
.group(
SettingGroup::new()
.title(t!("settings_group_font").to_string())
@@ -3652,9 +3701,9 @@ impl Ashell {
h_flex()
.items_center()
.gap_3()
.child(pointer_button("ui-font-size-down").small().label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(-1.0, cx))))
.child(pointer_button("ui-font-size-down").label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(-1.0, cx))))
.child(div().min_w(px(64.)).text_center().child(format!("{:.0}px", view.read(cx).ui_font_size)))
.child(pointer_button("ui-font-size-up").small().label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(1.0, cx))))
.child(pointer_button("ui-font-size-up").label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(1.0, cx))))
.into_any_element()
}
})
@@ -3669,9 +3718,9 @@ impl Ashell {
h_flex()
.items_center()
.gap_3()
.child(pointer_button("terminal-font-size-down").small().label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(-1.0, cx))))
.child(pointer_button("terminal-font-size-down").label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(-1.0, cx))))
.child(div().min_w(px(64.)).text_center().child(format!("{:.0}px", view.read(cx).terminal_font_size)))
.child(pointer_button("terminal-font-size-up").small().label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(1.0, cx))))
.child(pointer_button("terminal-font-size-up").label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(1.0, cx))))
.into_any_element()
}
})
@@ -3684,7 +3733,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, _window, cx| {
pointer_button("ui-font-dropdown")
.small()
.icon(IconName::ChevronsUpDown)
.label({
let current = view.read(cx).ui_font_family.to_string();
@@ -3748,7 +3797,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, _window, cx| {
pointer_button("terminal-font-dropdown")
.small()
.icon(IconName::ChevronsUpDown)
.label({
let current = view.read(cx).terminal_font_family.to_string();
@@ -3803,7 +3852,7 @@ impl Ashell {
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, {
@@ -3837,7 +3886,7 @@ impl Ashell {
use crate::session::config::CursorStyle;
let current = view.read(cx).cursor_style;
pointer_button("cursor-style-dropdown")
.small()
.icon(IconName::ChevronsUpDown)
.label(match current {
CursorStyle::Default => t!("cursor_style_default").to_string(),
@@ -3891,7 +3940,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, window, cx| {
pointer_switch("right-click-copy-paste")
.small()
.checked(view.read(cx).config.right_click_copy_paste())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_right_click_copy_paste(*checked);
@@ -3910,7 +3959,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, window, cx| {
pointer_switch("keyword-highlight")
.small()
.checked(view.read(cx).config.keyword_highlight())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_keyword_highlight(*checked);
@@ -3929,7 +3978,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, window, cx| {
pointer_switch("remember-tabs")
.small()
.checked(view.read(cx).config.remember_tabs())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_remember_tabs(*checked);
@@ -3949,7 +3998,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, window, cx| {
pointer_switch("lock-layout")
.small()
.checked(view.read(cx).config.lock_layout())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_lock_layout(*checked);
@@ -3968,7 +4017,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, _window, cx| {
pointer_button("monitoring-position-dropdown")
.small()
.icon(IconName::PanelLeftOpen)
.label({
let pos = view.read(cx).config.monitoring_position().to_string();
@@ -4027,7 +4076,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, _window, cx| {
pointer_button("language-dropdown")
.small()
.icon(IconName::Globe)
.label({
let current_locale = view.read(cx).config.locale().to_string();
@@ -4081,7 +4130,7 @@ impl Ashell {
let view = view_clone_for_general.clone();
move |_, window, _cx| {
pointer_button("reset-layout")
.small()
.label(t!("reset").to_string())
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.reset_layout(window, cx);
@@ -4108,7 +4157,7 @@ impl Ashell {
.gap_2()
.child(
pointer_button("backup-export")
.small()
.label(t!("backup_export").to_string())
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.export_local_config(window, cx);
@@ -4116,7 +4165,7 @@ impl Ashell {
)
.child(
pointer_button("backup-import")
.small()
.label(t!("backup_import").to_string())
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.import_local_config(window, cx);
@@ -4159,14 +4208,14 @@ impl Ashell {
.gap_2()
.child(
pointer_button("sync-backend-webdav")
.small()
.label("WebDAV")
.when(!is_s3, |button| button.primary())
.on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("webdav", cx)))
)
.child(
pointer_button("sync-backend-s3")
.small()
.label("S3")
.when(is_s3, |button| button.primary())
.on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("s3", cx)))
@@ -4190,8 +4239,8 @@ impl Ashell {
.child(
h_flex()
.gap_2()
.child(pointer_button("sync-download").small().disabled(in_progress).label(t!("sync_download").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.download_sync_config(cx))))
.child(pointer_button("sync-upload").small().disabled(in_progress).label(t!("sync_upload").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.upload_sync_config(cx)))),
.child(pointer_button("sync-download").disabled(in_progress).label(t!("sync_download").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.download_sync_config(cx))))
.child(pointer_button("sync-upload").disabled(in_progress).label(t!("sync_upload").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.upload_sync_config(cx)))),
)
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(status))
}
@@ -4211,7 +4260,7 @@ impl Ashell {
let view = view.clone();
move |_, window, cx| {
pointer_switch("use-proxy")
.small()
.checked(view.read(cx).config.use_proxy())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_use_proxy(*checked);
@@ -4230,7 +4279,7 @@ impl Ashell {
let view = view.clone();
move |_, window, cx| {
pointer_switch("read-env-proxy")
.small()
.checked(view.read(cx).config.read_env_proxy())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_read_env_proxy(*checked);
@@ -4259,7 +4308,7 @@ impl Ashell {
.gap_2()
.child(
pointer_button("global-proxy-type-socks5")
.small()
.label("SOCKS5")
.when(proxy_type == "socks5", |b| b.primary())
.on_click(window.listener_for(&view, |this, _, _, cx| {
@@ -4269,7 +4318,7 @@ impl Ashell {
)
.child(
pointer_button("global-proxy-type-http")
.small()
.label("HTTP")
.when(proxy_type == "http", |b| b.primary())
.on_click(window.listener_for(&view, |this, _, _, cx| {
@@ -4284,7 +4333,7 @@ impl Ashell {
.child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_password").to_string())).child(Input::new(&global_proxy_password_input).w_full()))
.child(
pointer_button("save-global-proxy")
.small()
.primary()
.label(t!("save_proxy").to_string())
.on_click(window.listener_for(&view, |this, _, _, cx| {
@@ -4333,17 +4382,17 @@ impl Ashell {
v_flex()
.gap_2()
.items_center()
.child(div().text_size(rems(1.5)).font_weight(FontWeight::BOLD).child("Ashell"))
.child(div().text_size(rems(0.9)).child(format!("Version {}", version)))
.child(div().text_size(ui_rems(1.5)).font_weight(FontWeight::BOLD).child("Ashell"))
.child(div().text_size(ui_rems(0.9)).child(format!("Version {}", version)))
.child(
div()
.text_size(rems(0.9))
.text_size(ui_rems(0.9))
.text_color(cx.theme().muted_foreground)
.child("A GPUI Component based SSH and local terminal client"),
)
.child(
div()
.text_size(rems(0.9))
.text_size(ui_rems(0.9))
.text_color(cx.theme().muted_foreground)
.child(t!("about_feedback_hint")),
)
+29 -15
View File
@@ -9,6 +9,7 @@ pub mod startup;
pub mod system_menu;
pub mod theme;
pub mod ui;
pub mod window_drag;
use std::{
cell::{Cell, RefCell},
@@ -358,6 +359,7 @@ pub(crate) struct Ashell {
pub(crate) sftp_panel_minimized: bool,
pub(crate) sidebar_collapsed: bool,
pub(crate) window_active: bool,
pub(crate) should_move_window: bool,
pub(crate) native_window_handle: Option<isize>,
pub(crate) unread_terminal_notifications: HashSet<String>,
pub(crate) prev_monitoring_size: Option<Pixels>,
@@ -402,7 +404,6 @@ pub(crate) struct Ashell {
pub(crate) cmd_ctrl_pressed: bool,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
pub(crate) last_window_bounds: Option<gpui::WindowBounds>,
pub(crate) window_bounds_save_task: Option<gpui::Task<()>>,
pub(crate) save_lock: std::sync::Arc<std::sync::Mutex<()>>,
pub(crate) save_latest_seq: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
@@ -882,6 +883,7 @@ impl Ashell {
sftp_panel_minimized: config.sftp_panel_minimized(),
sidebar_collapsed: config.sidebar_collapsed(),
window_active: window.is_window_active(),
should_move_window: false,
native_window_handle: crate::desktop_notification::native_window_handle(window),
unread_terminal_notifications: HashSet::new(),
prev_monitoring_size: None,
@@ -923,13 +925,21 @@ impl Ashell {
cmd_ctrl_pressed: false,
_subscriptions,
last_window_bounds: Some(window.window_bounds()),
window_bounds_save_task: None,
save_lock: std::sync::Arc::new(std::sync::Mutex::new(())),
save_latest_seq: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
};
let local_shell_fallback = this.normalize_local_terminal_shell();
this.apply_theme_preferences(window, cx);
this.restore_saved_tabs(window, cx);
if let Some(fallback) = local_shell_fallback {
this.status = t!(
"local_terminal_shell_fallback",
terminal = crate::session::local_terminal_shell_label(fallback)
)
.to_string()
.into();
}
this.report_active_terminal_focus(this.window_active);
this.start_event_pump(window, cx);
this
@@ -958,6 +968,15 @@ impl Ashell {
self.sidebar_collapsed = self.config.sidebar_collapsed();
self.active_title_bar_style = self.config.title_bar_style();
if let Some(fallback) = self.normalize_local_terminal_shell() {
self.status = t!(
"local_terminal_shell_fallback",
terminal = crate::session::local_terminal_shell_label(fallback)
)
.to_string()
.into();
}
// Apply theme preferences
self.apply_theme_preferences(window, cx);
@@ -1609,9 +1628,7 @@ impl Ashell {
}
}
BackendEvent::TerminalTitleChanged { tab_id, title } => {
let local_path = title
.strip_prefix("ASHELL_CWD_B64:")
.and_then(crate::session::decode_local_path_title);
let local_path = crate::session::parse_local_directory_title(&title);
if let Some(path) = local_path {
self.apply_local_directory_change(&tab_id, path);
} else if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
@@ -2210,21 +2227,18 @@ impl Ashell {
}
self.last_window_bounds = Some(current_bounds);
self.window_bounds_save_task = Some(cx.spawn_in(window, async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
let _ = this.update_in(cx, |this, window, cx| {
if this.capture_layout_state(window, cx) {
this.save_preferences_background();
}
});
}));
// Save synchronously on the main thread. Background saves are killed by
// ExitProcess on Windows before they can flush during shutdown.
if self.capture_layout_state(window, cx) {
self.save_layout_state(window, cx);
}
}
fn save_layout_on_app_quit(&mut self, cx: &mut Context<Self>) -> gpui::Task<()> {
crate::desktop_notification::clear_unread_indicator(self.native_window_handle);
let entity_id = cx.entity_id();
// Save synchronously: GPUI gives quit handlers only 100ms, and Windows
// ExitProcess kills background tasks before they can flush.
let _ = cx.with_window(entity_id, |window, cx| {
self.save_layout_state(window, cx);
});
+10 -8
View File
@@ -2,7 +2,7 @@ use std::collections::HashMap;
use gpui::{
Context, Focusable as _, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
@@ -10,7 +10,10 @@ use gpui_component::{
};
use rust_i18n::t;
use crate::{Ashell, app::controls::pointer_button};
use crate::{
Ashell,
app::controls::{pointer_button, ui_rems},
};
impl Ashell {
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -325,7 +328,6 @@ impl Ashell {
div().child(
pointer_button("search-btn")
.ghost()
.small()
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
@@ -389,12 +391,12 @@ impl Ashell {
}
},
))
.child(Input::new(&self.search_input).small()),
.child(Input::new(&self.search_input)),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(rems(0.75))
.text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
@@ -404,7 +406,7 @@ impl Ashell {
.child(
pointer_button("search-prev")
.ghost()
.xsmall()
.small()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
@@ -414,7 +416,7 @@ impl Ashell {
.child(
pointer_button("search-next")
.ghost()
.xsmall()
.small()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
@@ -424,7 +426,7 @@ impl Ashell {
.child(
pointer_button("search-close")
.ghost()
.xsmall()
.small()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, window, cx| {
this.close_search(window, cx);
+266 -152
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
//! Window dragging helpers for the integrated title bar.
//!
//! GPUI implements `Window::start_window_move` on macOS and Linux, but the
//! Windows backend leaves it as a no-op. On Windows we post a native
//! `WM_SYSCOMMAND` / `SC_MOVE | HTCAPTION` message so the GPUI pointer callback
//! can finish before `DefWindowProc` enters its modal window-move loop.
/// Begin a native window drag from a mouse-down on the integrated title bar.
pub(crate) fn start_window_drag(window: &gpui::Window) {
#[cfg(target_os = "windows")]
{
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
use windows::Win32::UI::WindowsAndMessaging::{
HTCAPTION, PostMessageW, SC_MOVE, WM_SYSCOMMAND,
};
let Some(handle) = crate::desktop_notification::native_window_handle(window) else {
tracing::warn!("failed to start window drag: missing native window handle");
return;
};
unsafe {
// The button-down capture must be released before the system move
// loop can take over the pointer.
let _ = ReleaseCapture();
if let Err(err) = PostMessageW(
HWND(handle as _),
WM_SYSCOMMAND,
WPARAM((SC_MOVE | HTCAPTION) as usize),
LPARAM(0),
) {
tracing::warn!("failed to start native window drag: {err}");
}
}
}
#[cfg(not(target_os = "windows"))]
{
window.start_window_move();
}
}
+192 -10
View File
@@ -1,6 +1,6 @@
use std::{
io::{Read, Write},
path::Path,
path::{Path, PathBuf},
sync::mpsc,
thread,
time::Duration,
@@ -14,6 +14,7 @@ use portable_pty::{CommandBuilder, PtySize, native_pty_system};
#[cfg(not(windows))]
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
use crate::session::config::LocalTerminalShell;
use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender};
#[cfg(not(windows))]
@@ -32,12 +33,172 @@ fn local_process_directory(system: &mut System, pid: Pid) -> Option<std::path::P
.map(std::path::PathBuf::from)
}
#[derive(Debug, Clone)]
pub struct LocalTerminalShellLaunch {
pub executable: PathBuf,
}
/// Resolves a configured local shell to an executable available on this host.
pub fn resolve_local_terminal_shell(
shell: LocalTerminalShell,
) -> anyhow::Result<LocalTerminalShellLaunch> {
#[cfg(not(windows))]
let _ = shell;
#[cfg(windows)]
{
let executable = match shell {
LocalTerminalShell::WindowsPowerShell => resolve_windows_powershell(),
LocalTerminalShell::PowerShell7 => resolve_powershell7(),
LocalTerminalShell::CommandPrompt => resolve_command_prompt(),
LocalTerminalShell::GitBash => resolve_git_bash(),
}
.ok_or_else(|| anyhow::anyhow!("configured local shell is not installed: {shell:?}"))?;
return Ok(LocalTerminalShellLaunch { executable });
}
#[cfg(not(windows))]
{
let executable = std::env::var_os("SHELL")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/bin/zsh"));
Ok(LocalTerminalShellLaunch { executable })
}
}
pub fn local_terminal_shell_available(shell: LocalTerminalShell) -> bool {
resolve_local_terminal_shell(shell).is_ok()
}
#[cfg(windows)]
fn existing_file(path: PathBuf) -> Option<PathBuf> {
path.is_file().then_some(path)
}
#[cfg(windows)]
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name).map(PathBuf::from)
}
#[cfg(windows)]
fn find_on_path(name: &str, predicate: impl Fn(&Path) -> bool) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(name))
.find(|candidate| candidate.is_file() && predicate(candidate))
}
#[cfg(windows)]
fn resolve_windows_powershell() -> Option<PathBuf> {
let system_root = env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"))
.or_else(|| find_on_path("powershell.exe", |_| true))
}
#[cfg(windows)]
fn resolve_powershell7() -> Option<PathBuf> {
let mut roots = Vec::new();
if let Some(program_files) = env_path("ProgramFiles") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramW6432") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramFiles(x86)") {
roots.push(program_files.join("PowerShell"));
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
roots.push(local_app_data.join(r"Programs\PowerShell"));
}
let mut candidates = Vec::new();
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Microsoft\WindowsApps\pwsh.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\powershell\current\pwsh.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\powershell\current\pwsh.exe"));
}
for root in roots {
if let Some(candidate) = existing_file(root.join("pwsh.exe")) {
candidates.push(candidate);
}
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
let mut versions = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.chars()
.all(|character| character.is_ascii_digit() || character == '.')
})
.collect::<Vec<_>>();
versions.sort_by_key(|entry| entry.file_name());
candidates.extend(
versions
.into_iter()
.rev()
.filter_map(|entry| existing_file(entry.path().join("pwsh.exe"))),
);
}
candidates
.into_iter()
.next()
.or_else(|| find_on_path("pwsh.exe", |_| true))
}
#[cfg(windows)]
fn resolve_command_prompt() -> Option<PathBuf> {
env_path("ComSpec")
.and_then(existing_file)
.or_else(|| {
let system_root =
env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\cmd.exe"))
})
.or_else(|| find_on_path("cmd.exe", |_| true))
}
#[cfg(windows)]
fn resolve_git_bash() -> Option<PathBuf> {
let mut candidates = Vec::new();
for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(program_files) = env_path(variable) {
candidates.push(program_files.join(r"Git\bin\bash.exe"));
}
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Programs\Git\bin\bash.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\git\current\bin\bash.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\git\current\bin\bash.exe"));
}
candidates.into_iter().find_map(existing_file).or_else(|| {
find_on_path("bash.exe", |candidate| {
let path = candidate.to_string_lossy().to_ascii_lowercase();
path.contains("\\git\\") || path.contains("/git/")
})
})
}
pub fn spawn_local_terminal_at(
tab_id: String,
cols: u16,
rows: u16,
events: GuardedBackendEventSender,
initial_directory: Option<&Path>,
shell: LocalTerminalShell,
) -> Result<BackendTx> {
let pty_system = native_pty_system();
let pair = pty_system
@@ -49,13 +210,8 @@ pub fn spawn_local_terminal_at(
})
.context("open local PTY")?;
let shell = if cfg!(windows) {
"powershell.exe".to_string()
} else {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into())
};
let mut cmd = CommandBuilder::new(&shell);
let launch = resolve_local_terminal_shell(shell).context("resolve local shell")?;
let mut cmd = CommandBuilder::new(&launch.executable);
#[cfg(windows)]
{
const POWERSHELL_CWD_REPORTER: &str = r#"& {
@@ -69,7 +225,33 @@ pub fn spawn_local_terminal_at(
"$promptText$([char]27)]133;B$([char]7)"
}
}"#;
cmd.args(["-NoLogo", "-NoExit", "-Command", POWERSHELL_CWD_REPORTER]);
match shell {
LocalTerminalShell::WindowsPowerShell | LocalTerminalShell::PowerShell7 => {
cmd.args(["-NoLogo", "-NoExit", "-Command", POWERSHELL_CWD_REPORTER]);
}
LocalTerminalShell::CommandPrompt => {
let original_prompt =
std::env::var("PROMPT").unwrap_or_else(|_| "$P$G".to_string());
let prompt = format!(
"\x1b]133;D\x07\x1b]133;A\x07\x1b]0;ASHELL_CWD:$P\x07{original_prompt}\x1b]133;B\x07"
);
cmd.args(["/Q"]);
cmd.env("PROMPT", prompt);
}
LocalTerminalShell::GitBash => {
let reporter = r#"printf '\033]133;D\a\033]133;A\a\033]0;ASHELL_CWD:%s\a' "$(pwd -W 2>/dev/null || pwd)""#;
let original_prompt_command = std::env::var("PROMPT_COMMAND").ok();
let prompt_command = match original_prompt_command {
Some(command) if !command.trim().is_empty() => {
format!("{reporter};{command}")
}
_ => reporter.to_string(),
};
cmd.args(["--login", "-i"]);
cmd.env("CHERE_INVOKING", "1");
cmd.env("PROMPT_COMMAND", prompt_command);
}
}
}
cmd.env(
"TERM",
@@ -94,7 +276,7 @@ pub fn spawn_local_terminal_at(
if let Some(directory) = initial_directory.filter(|path| path.is_dir()) {
cmd.cwd(directory.as_os_str());
}
cmd.env("SHELL", shell);
cmd.env("SHELL", launch.executable.as_os_str());
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
#[cfg(not(windows))]
let child_pid = child.process_id().map(Pid::from_u32);
+71 -21
View File
@@ -487,12 +487,7 @@ async fn connect_and_authenticate(
session: &Session,
events: &GuardedBackendEventSender,
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: None,
keepalive_interval: Some(std::time::Duration::from_secs(5)),
keepalive_max: 3,
..Default::default()
});
let config = Arc::new(crate::session::config::ssh_client_config());
let addr = format!("{}:{}", session.host, session.port);
tracing::info!(
"[ssh] initiating tcp connection to {} (user: {})",
@@ -795,10 +790,12 @@ fn key_source_label(session: &Session) -> String {
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
LC_ALL=C
export LC_ALL
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
cpu_stat() { awk '"'"'/^cpu / { total = ($2+$3+$4+$5+$6+$7+$8); printf "%.0f %.0f\n", total, $5 }'"'"' /proc/stat 2>/dev/null; }
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { printf "%.0f %.0f\n", rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
read cpu_total_1 cpu_idle_1 <<EOF
$(cpu_stat)
@@ -814,22 +811,62 @@ EOF
$(net_stat)
EOF
cpu_total_1=${cpu_total_1:-0}
cpu_idle_1=${cpu_idle_1:-0}
cpu_total_2=${cpu_total_2:-0}
cpu_idle_2=${cpu_idle_2:-0}
net_rx_1=${net_rx_1:-0}
net_tx_1=${net_tx_1:-0}
net_rx_2=${net_rx_2:-0}
net_tx_2=${net_tx_2:-0}
cpu_delta=$((cpu_total_2 - cpu_total_1))
idle_delta=$((cpu_idle_2 - cpu_idle_1))
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
# metrics normalization
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN {
if (total <= 0) {
print "0.00"
} else {
usage = ((total-idle)/total)*100
if (usage < 0) usage = 0
if (usage > 100) usage = 100
printf "%.2f", usage
}
}'"'"')
mem_available=$(awk '"'"'
/^MemTotal:/ { total = $2 * 1024 }
/^MemAvailable:/ { available = $2 * 1024; has_available = 1 }
/^MemFree:/ { free = $2 * 1024 }
/^Buffers:/ { buffers = $2 * 1024 }
/^Cached:/ { cached = $2 * 1024 }
/^SReclaimable:/ { reclaimable = $2 * 1024 }
/^Shmem:/ { shmem = $2 * 1024 }
END {
if (!has_available) available = free + buffers + cached + reclaimable - shmem
if (available < 0) available = 0
if (total > 0 && available > total) available = total
printf "%.0f\n", available
}
'"'"' /proc/meminfo 2>/dev/null)
mem_total=$(awk '"'"'/^MemTotal:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
swap_total=$(awk '"'"'/^SwapTotal:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
swap_free=$(awk '"'"'/^SwapFree:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
mem_used=$(( ${mem_total:-0} - ${mem_available:-0} ))
[ "$mem_used" -lt 0 ] && mem_used=0
swap_used=$(( ${swap_total:-0} - ${swap_free:-0} ))
[ "$swap_used" -lt 0 ] && swap_used=0
net_rx=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))
[ "$net_rx" -lt 0 ] && net_rx=0
net_tx=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))
[ "$net_tx" -lt 0 ] && net_tx=0
echo "CPU_PERCENT=${cpu_percent:-0.00}"
echo "MEM_TOTAL=${mem_total:-0}"
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
echo "MEM_USED=$mem_used"
echo "SWAP_TOTAL=${swap_total:-0}"
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
echo "SWAP_USED=$swap_used"
echo "NET_RX=$net_rx"
echo "NET_TX=$net_tx"
LC_ALL=C df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%.0f\t%.0f\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
@@ -878,6 +915,8 @@ exit 2
const REMOTE_PROCESS_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
LC_ALL=C
export LC_ALL
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
before=$(mktemp "${TMPDIR:-/tmp}/ashell-process-before.XXXXXX") || exit 1
@@ -958,7 +997,10 @@ exit 2
'"#;
const REMOTE_PORT_PROBE: &str = r#"sh -lc '
LC_ALL=C
export LC_ALL
if command -v lsof >/dev/null 2>&1; then
lsof_output=$(
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '"'"'
NR > 1 && $2 ~ /^[0-9]+$/ {
endpoint = $9;
@@ -986,11 +1028,15 @@ if command -v lsof >/dev/null 2>&1; then
printf "PORT\tUDP\t%s\t%s\tUNCONN\t%s\t%s\n", address, port, $2, $1;
}
'"'"'
exit 0
)
if [ -n "$lsof_output" ]; then
printf "%s\n" "$lsof_output"
exit 0
fi
fi
if command -v ss >/dev/null 2>&1; then
ss -H -lntup 2>/dev/null | awk '"'"'
ss_output=$(ss -lntup 2>/dev/null | awk '"'"'
NF >= 5 {
protocol = $1;
state = $2;
@@ -1017,7 +1063,11 @@ if command -v ss >/dev/null 2>&1; then
printf "PORT\t%s\t%s\t%s\t%s\t%s\t%s\n", protocol, address, port, state, pid, process;
}
'"'"'
exit 0
)
if [ -n "$ss_output" ]; then
printf "%s\n" "$ss_output"
exit 0
fi
fi
if command -v netstat >/dev/null 2>&1; then
+87 -4
View File
@@ -257,6 +257,17 @@ pub enum CursorStyle {
BeamBlink,
}
/// Selects the shell used when a new local terminal is opened on Windows.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum LocalTerminalShell {
#[default]
WindowsPowerShell,
PowerShell7,
CommandPrompt,
GitBash,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default = "default_follow_system_theme")]
@@ -273,6 +284,8 @@ pub struct ConfigFile {
pub terminal_font_size: f32,
#[serde(default)]
pub local_terminal_encoding: TextEncoding,
#[serde(default)]
pub local_terminal_shell: LocalTerminalShell,
#[serde(default = "default_ui_font_size")]
pub ui_font_size: f32,
#[serde(default)]
@@ -393,9 +406,12 @@ fn default_locale() -> String {
}
fn default_terminal_font_size() -> f32 {
18.0
12.0
}
const LEGACY_DEFAULT_TERMINAL_FONT_SIZE: f32 = 18.0;
const LEGACY_DEFAULT_UI_FONT_SIZE: f32 = 16.0;
fn default_ui_font_size() -> f32 {
14.0
}
@@ -449,6 +465,7 @@ impl Default for ConfigFile {
locale: default_locale(),
terminal_font_size: default_terminal_font_size(),
local_terminal_encoding: TextEncoding::Utf8,
local_terminal_shell: LocalTerminalShell::default(),
ui_font_size: default_ui_font_size(),
right_click_copy_paste: false,
keyword_highlight: false,
@@ -1150,7 +1167,9 @@ impl ConfigStore {
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 {
if self.cache.terminal_font_size <= 0.0
|| (self.cache.terminal_font_size - LEGACY_DEFAULT_TERMINAL_FONT_SIZE).abs() < 0.01
{
default_terminal_font_size()
} else {
self.cache.terminal_font_size
@@ -1260,8 +1279,19 @@ impl ConfigStore {
self.cache.local_terminal_encoding = encoding;
}
pub fn local_terminal_shell(&self) -> LocalTerminalShell {
self.cache.local_terminal_shell
}
pub fn set_local_terminal_shell(&mut self, shell: LocalTerminalShell) {
self.cache.local_terminal_shell = shell;
}
pub fn ui_font_size(&self) -> f32 {
if self.cache.ui_font_size <= 0.0 {
if self.cache.ui_font_size <= 0.0
|| (self.cache.ui_font_size - LEGACY_DEFAULT_UI_FONT_SIZE).abs() < 0.01
{
// Migrate the previous application default to the current compact default.
default_ui_font_size()
} else {
self.cache.ui_font_size
@@ -1269,7 +1299,7 @@ impl ConfigStore {
}
pub fn set_ui_font_size(&mut self, ui_font_size: f32) {
self.cache.ui_font_size = ui_font_size.max(8.0);
self.cache.ui_font_size = ui_font_size.clamp(10.0, 28.0);
}
pub fn ui_font_family(&self) -> &str {
@@ -1480,6 +1510,7 @@ impl ConfigStore {
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.local_terminal_shell = local_config.local_terminal_shell;
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;
@@ -1528,6 +1559,21 @@ impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'st
{
}
/// Build the shared russh client configuration used by terminal and SFTP sessions.
///
/// Keep russh's modern key-exchange order intact, then add the SHA-1 group14
/// algorithm as a final fallback for legacy OpenSSH servers such as OpenSSH 5.x.
pub(crate) fn ssh_client_config() -> russh::client::Config {
let mut config = russh::client::Config {
inactivity_timeout: None,
keepalive_interval: Some(std::time::Duration::from_secs(5)),
keepalive_max: 3,
..Default::default()
};
config.preferred.kex.to_mut().push(russh::kex::DH_G14_SHA1);
config
}
#[derive(Debug, Clone)]
pub struct EnvProxy {
pub proxy_type: String,
@@ -1889,9 +1935,39 @@ mod tests {
assert!(!config.sftp_file_columns_customized);
assert!(config.connection_groups.is_empty());
assert_eq!(config.local_terminal_encoding, TextEncoding::Utf8);
assert_eq!(
config.local_terminal_shell,
LocalTerminalShell::WindowsPowerShell
);
assert!(config.collapsed_connection_groups.is_empty());
}
#[test]
fn font_sizes_migrate_the_previous_defaults() {
let mut store = ConfigStore::in_memory();
assert_eq!(store.terminal_font_size(), 12.0);
assert_eq!(store.ui_font_size(), 14.0);
store.set_terminal_font_size(18.0);
assert_eq!(store.terminal_font_size(), 12.0);
store.set_ui_font_size(14.0);
assert_eq!(store.ui_font_size(), 14.0);
store.set_ui_font_size(20.0);
assert_eq!(store.ui_font_size(), 20.0);
}
#[test]
fn legacy_ssh_key_exchange_is_only_a_fallback() {
let config = ssh_client_config();
let kex = config.preferred.kex.as_ref();
assert_eq!(kex.last(), Some(&russh::kex::DH_G14_SHA1));
assert_eq!(kex.first(), Some(&russh::kex::CURVE25519));
}
#[test]
fn legacy_sessions_default_to_the_ungrouped_section() {
let session: Session = serde_json::from_str(
@@ -1966,6 +2042,7 @@ mod tests {
#[test]
fn test_saved_tabs_roundtrip() {
let config = ConfigFile {
local_terminal_shell: LocalTerminalShell::PowerShell7,
remember_tabs: true,
saved_tabs: Some(SavedTabsState {
groups: vec![SavedTabGroup {
@@ -1990,6 +2067,10 @@ mod tests {
let restored: ConfigFile = serde_json::from_str(&json).unwrap();
assert!(restored.remember_tabs);
assert_eq!(
restored.local_terminal_shell,
LocalTerminalShell::PowerShell7
);
let restored_tabs = restored.saved_tabs.unwrap();
assert_eq!(restored_tabs.groups.len(), 1);
assert_eq!(restored_tabs.active_tab.as_deref(), Some("tab-1"));
@@ -2034,6 +2115,7 @@ mod tests {
ui_font_size: 18.0,
terminal_font_size: 20.0,
local_terminal_encoding: TextEncoding::Gbk,
local_terminal_shell: LocalTerminalShell::GitBash,
show_hidden_files: true,
sftp_file_columns_customized: true,
remember_tabs: true,
@@ -2072,6 +2154,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_eq!(decrypted.local_terminal_shell, LocalTerminalShell::GitBash);
assert!(decrypted.show_hidden_files);
assert!(decrypted.sftp_file_columns_customized);
assert!(decrypted.remember_tabs);
+336 -111
View File
@@ -13,7 +13,8 @@ use std::time::Duration;
use uuid::Uuid;
use self::config::{
AuthMethod, SavedPaneLayout, SavedTabGroup, SavedTabsState, SavedTerminalTab, Session,
AuthMethod, LocalTerminalShell, SavedPaneLayout, SavedTabGroup, SavedTabsState,
SavedTerminalTab, Session,
};
use crate::{
@@ -24,6 +25,12 @@ use crate::{
text_encoding::TextEncoding,
};
const MIN_TERMINAL_GRID_DIMENSION: u16 = 2;
fn valid_terminal_layout_size(cols: u16, rows: u16) -> bool {
cols >= MIN_TERMINAL_GRID_DIMENSION && rows >= MIN_TERMINAL_GRID_DIMENSION
}
pub(crate) fn compact_local_path(path: &std::path::Path) -> String {
if let Some(base_dirs) = directories::BaseDirs::new() {
if let Some(relative_path) = relative_to_home(path, base_dirs.home_dir()) {
@@ -71,6 +78,27 @@ pub(crate) fn decode_local_path_title(encoded: &str) -> Option<std::path::PathBu
path.is_absolute().then_some(path)
}
pub(crate) fn parse_local_directory_title(title: &str) -> Option<std::path::PathBuf> {
if let Some(encoded) = title.strip_prefix("ASHELL_CWD_B64:") {
return decode_local_path_title(encoded);
}
let path = title.strip_prefix("ASHELL_CWD:")?.trim();
let path = std::path::PathBuf::from(path);
path.is_absolute().then_some(path)
}
pub(crate) fn local_terminal_shell_label(shell: LocalTerminalShell) -> String {
match shell {
LocalTerminalShell::WindowsPowerShell => {
t!("local_terminal_windows_powershell").to_string()
}
LocalTerminalShell::PowerShell7 => t!("local_terminal_powershell_7").to_string(),
LocalTerminalShell::CommandPrompt => t!("local_terminal_cmd").to_string(),
LocalTerminalShell::GitBash => t!("local_terminal_git_bash").to_string(),
}
}
fn default_local_directory() -> Option<std::path::PathBuf> {
directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
@@ -139,6 +167,35 @@ fn restore_pane_layout(layout: &SavedPaneLayout) -> PaneLayout {
}
impl Ashell {
pub(crate) fn normalize_local_terminal_shell(&mut self) -> Option<LocalTerminalShell> {
let selected = self.config.local_terminal_shell();
if local::local_terminal_shell_available(selected) {
return None;
}
let fallback =
if local::local_terminal_shell_available(LocalTerminalShell::WindowsPowerShell) {
LocalTerminalShell::WindowsPowerShell
} else {
LocalTerminalShell::CommandPrompt
};
if selected == fallback {
return None;
}
self.config.set_local_terminal_shell(fallback);
self.save_preferences_background();
Some(fallback)
}
fn local_shell_for_launch(&mut self) -> (LocalTerminalShell, bool) {
let selected = self.config.local_terminal_shell();
if let Some(fallback) = self.normalize_local_terminal_shell() {
return (fallback, true);
}
(selected, false)
}
pub(crate) fn apply_local_directory_change(&mut self, tab_id: &str, path: std::path::PathBuf) {
let title = compact_local_path(&path);
let is_local = if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == tab_id) {
@@ -289,24 +346,15 @@ impl Ashell {
.unwrap_or_else(initial_local_title);
let backend_events =
crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone());
let backend = match local::spawn_local_terminal_at(
// The restored pane does not have reliable dimensions until its
// first layout pass. Starting the shell now at DEFAULT_COLS would
// make zsh redraw its prompt after the real PTY resize.
let mut tab = TerminalTab::new_local(
id.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
backend_events.clone(),
cwd.as_deref(),
) {
Ok(backend) => backend,
Err(err) => {
tracing::warn!(
"[session] failed to restore local tab '{}': {err:#}",
id
);
continue;
}
};
let mut tab =
TerminalTab::new_local(id.clone(), title, backend, backend_events);
title,
crate::terminal::BackendTx::Pending,
backend_events,
);
tab.local_cwd = cwd;
tab.set_text_encoding(terminal_encoding);
(id, tab)
@@ -464,9 +512,13 @@ impl Ashell {
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 !valid_terminal_layout_size(cols, rows) {
return None;
}
let Some(tab_index) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
return None;
};
if self.tabs[tab_index].kind != TabKind::Local {
let keyword_highlight = self.config.keyword_highlight();
@@ -476,7 +528,11 @@ impl Ashell {
return None;
}
if self.tabs[tab_index].cols == cols && self.tabs[tab_index].rows == rows {
let backend_start_pending = self.tabs[tab_index].backend_start_pending();
if !backend_start_pending
&& 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;
@@ -498,66 +554,127 @@ impl Ashell {
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;
let mut changed = false;
for (tab_id, (cols, rows)) in pending {
if let Some(tab) = this
let Some(tab_index) = this
.tabs
.iter_mut()
.find(|tab| tab.id == tab_id && tab.kind == TabKind::Local)
{
resized |= tab.resize(cols, rows);
.iter()
.position(|tab| tab.id == tab_id && tab.kind == TabKind::Local)
else {
continue;
};
if this.tabs[tab_index].backend_start_pending() {
changed |= this.start_pending_local_terminal(tab_index, cols, rows);
} else {
changed |= this.tabs[tab_index].resize(cols, rows);
}
}
if resized {
if changed {
cx.notify();
}
});
}));
None
}
/// Starts a local shell only after its pane has a stable grid size.
fn start_pending_local_terminal(&mut self, tab_index: usize, cols: u16, rows: u16) -> bool {
if !self.tabs[tab_index].backend_start_pending() {
return false;
}
// Resize the emulator before spawning the shell so its first output is
// interpreted with exactly the same dimensions as the PTY.
self.tabs[tab_index].resize(cols, rows);
let tab_id = self.tabs[tab_index].id.clone();
let local_cwd = self.tabs[tab_index]
.local_cwd
.clone()
.or_else(default_local_directory);
let backend_events = self.tabs[tab_index].backend_events();
let (shell, fallback) = self.local_shell_for_launch();
match local::spawn_local_terminal_at(
tab_id.clone(),
cols,
rows,
backend_events,
local_cwd.as_deref(),
shell,
) {
Ok(backend) => {
self.tabs[tab_index].set_backend(backend);
self.tabs[tab_index].connected = true;
self.tabs[tab_index].status = if fallback {
t!(
"local_terminal_shell_fallback",
terminal = local_terminal_shell_label(shell)
)
.to_string()
.into()
} else {
"local shell".into()
};
self.tabs[tab_index].disconnected_reason = None;
self.tabs[tab_index].local_cwd = local_cwd;
}
Err(err) => {
let reason = format!("failed to start local terminal: {err:#}");
tracing::warn!("[session] {reason}");
self.tabs[tab_index].connected = false;
self.tabs[tab_index].status = reason.clone();
self.tabs[tab_index].disconnected_reason = Some(reason.clone());
self.status = reason.into();
}
}
true
}
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();
let initial_directory = default_local_directory();
let backend_events =
crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone());
match local::spawn_local_terminal_at(
let (shell, fallback) = self.local_shell_for_launch();
let title = initial_local_title();
// ConPTY reflows startup output when resized, so wait for the first
// stable layout before spawning the shell at its final grid size.
let mut tab = TerminalTab::new_local(
id.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
backend_events.clone(),
initial_directory.as_deref(),
) {
Ok(backend) => {
let title = initial_local_title();
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());
self.pane_root = PaneLayout::Single(id.clone());
self.focused_pane_path = vec![];
let group_id = Uuid::new_v4().to_string();
self.tab_groups.push(TabGroup {
id: group_id.clone(),
title,
pane_root: PaneLayout::Single(id),
sftp: None,
sftp_tab_id: None,
});
self.active_group = Some(group_id);
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
self.sync_system_tab_to_active_group();
self.status = "local terminal opened".into();
}
Err(err) => {
self.status = format!("failed to open local terminal: {err:#}").into();
}
}
title.clone(),
crate::terminal::BackendTx::Pending,
backend_events,
);
tab.local_cwd = initial_directory;
tab.set_text_encoding(self.config.local_terminal_encoding());
self.tabs.push(tab);
self.active_tab = Some(id.clone());
self.pane_root = PaneLayout::Single(id.clone());
self.focused_pane_path = vec![];
let group_id = Uuid::new_v4().to_string();
self.tab_groups.push(TabGroup {
id: group_id.clone(),
title,
pane_root: PaneLayout::Single(id),
sftp: None,
sftp_tab_id: None,
});
self.active_group = Some(group_id);
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
self.sync_system_tab_to_active_group();
self.status = if fallback {
t!(
"local_terminal_shell_fallback",
terminal = local_terminal_shell_label(shell)
)
.to_string()
.into()
} else {
"local terminal opened".into()
};
self.update_terminal_focus(previous_active_tab.as_deref());
self.save_tabs_state_background();
cx.notify();
@@ -700,7 +817,7 @@ impl Ashell {
}
if !is_editing {
self.open_ssh_session(session, cx);
self.open_ssh_session(session, window, cx);
}
self.editing_session_id = None;
self.active_dialog = None;
@@ -900,7 +1017,7 @@ impl Ashell {
}
pub(crate) fn change_ui_font_size(&mut self, delta: f32, cx: &mut Context<Self>) {
self.ui_font_size = (self.ui_font_size + delta).clamp(8.0, 24.0);
self.ui_font_size = (self.ui_font_size + delta).clamp(10.0, 28.0);
self.config.set_ui_font_size(self.ui_font_size);
self.save_preferences_background();
Theme::global_mut(cx).font_size = px(self.ui_font_size);
@@ -1051,6 +1168,37 @@ impl Ashell {
cx.notify();
}
pub(crate) fn change_local_terminal_shell(
&mut self,
shell: LocalTerminalShell,
cx: &mut Context<Self>,
) {
if !local::local_terminal_shell_available(shell) {
let (fallback, _) = self.local_shell_for_launch();
self.status = t!(
"local_terminal_shell_fallback",
terminal = local_terminal_shell_label(fallback)
)
.to_string()
.into();
cx.notify();
return;
}
if self.config.local_terminal_shell() == shell {
return;
}
self.config.set_local_terminal_shell(shell);
self.save_preferences_background();
self.status = t!(
"local_terminal_shell_changed",
terminal = local_terminal_shell_label(shell)
)
.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();
@@ -1100,7 +1248,12 @@ impl Ashell {
cx.notify();
}
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
pub(crate) fn connect_saved_session(
&mut self,
session_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
tracing::info!(
"[ui] user clicked to connect saved session '{}'",
session_id
@@ -1113,7 +1266,7 @@ impl Ashell {
if session.protocol == "serial" {
self.open_serial_session(session, cx);
} else {
self.open_ssh_session(session, cx);
self.open_ssh_session(session, window, cx);
}
}
@@ -1173,7 +1326,7 @@ impl Ashell {
self.open_new_ssh_dialog(window, cx);
}
SelectorEntry::Saved(session_id) => {
self.connect_saved_session(session_id, cx);
self.connect_saved_session(session_id, window, cx);
window.close_dialog(cx);
}
}
@@ -1207,7 +1360,12 @@ impl Ashell {
}
}
pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
pub(crate) fn open_ssh_session(
&mut self,
session: Session,
window: &mut Window,
cx: &mut Context<Self>,
) {
let previous_active_tab = self.active_tab.clone();
tracing::info!(
"[session] opening ssh tab for session '{}' ({}@{})",
@@ -1250,7 +1408,7 @@ impl Ashell {
sftp_tab_id: Some(id.clone()),
});
self.active_group = Some(group_id.clone());
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
self.ensure_tab_visible(self.tab_groups.len() - 1, window, cx);
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self
.config
@@ -1535,6 +1693,7 @@ impl Ashell {
let session = self.tabs[ix].session.clone();
let cols = self.tabs[ix].cols;
let rows = self.tabs[ix].rows;
let mut local_shell_fallback = false;
let backend_events = self.tabs[ix].advance_backend_events();
// Advance the event generation before closing the old backend so its
@@ -1582,18 +1741,30 @@ impl Ashell {
.local_cwd
.clone()
.or_else(default_local_directory);
let (shell, fallback) = self.local_shell_for_launch();
local_shell_fallback = fallback;
match local::spawn_local_terminal_at(
tab_id.to_string(),
cols,
rows,
backend_events,
local_cwd.as_deref(),
shell,
) {
Ok(backend) => {
// Swap the backend — preserves terminal history.
self.tabs[ix].set_backend(backend);
self.tabs[ix].connected = true;
self.tabs[ix].status = "local shell".into();
self.tabs[ix].status = if fallback {
t!(
"local_terminal_shell_fallback",
terminal = local_terminal_shell_label(shell)
)
.to_string()
.into()
} else {
"local shell".into()
};
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].local_cwd = local_cwd;
// Resize the new PTY to match the pane dimensions.
@@ -1608,9 +1779,15 @@ impl Ashell {
}
self.status = if is_ssh {
"ssh tab retrying"
"ssh tab retrying".to_string()
} else if local_shell_fallback {
t!(
"local_terminal_shell_fallback",
terminal = local_terminal_shell_label(self.config.local_terminal_shell())
)
.to_string()
} else {
"local tab reopened"
"local tab reopened".to_string()
}
.into();
cx.notify();
@@ -1975,50 +2152,47 @@ impl Ashell {
Some(id) if !id.is_empty() => id.to_string(),
_ => return,
};
// Find current tab to clone its type/session
let current_tab = match self.tabs.iter().find(|t| t.id == current_id) {
Some(tab) => tab,
None => return,
// Find current tab to clone its type/session.
let (current_kind, current_session, local_cwd) = {
let Some(current_tab) = self.tabs.iter().find(|t| t.id == current_id) else {
return;
};
(
current_tab.kind,
current_tab.session.clone(),
current_tab
.local_cwd
.clone()
.or_else(default_local_directory),
)
};
let (shell, local_shell_fallback) = if current_kind == TabKind::Local {
self.local_shell_for_launch()
} else {
(LocalTerminalShell::default(), false)
};
let local_cwd = current_tab
.local_cwd
.clone()
.or_else(default_local_directory);
let new_id = Uuid::new_v4().to_string();
let backend_events =
crate::terminal::GuardedBackendEventSender::new(self.events_tx.clone());
let mut tab = match current_tab.kind {
let mut tab = match current_kind {
TabKind::Local => {
match local::spawn_local_terminal_at(
let title = local_cwd
.as_deref()
.map(compact_local_path)
.unwrap_or_else(initial_local_title);
// The split pane's dimensions are unknown until its first layout pass.
let mut tab = TerminalTab::new_local(
new_id.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
title,
crate::terminal::BackendTx::Pending,
backend_events.clone(),
local_cwd.as_deref(),
) {
Ok(backend) => {
let title = local_cwd
.as_deref()
.map(compact_local_path)
.unwrap_or_else(initial_local_title);
let mut tab = TerminalTab::new_local(
new_id.clone(),
title,
backend,
backend_events.clone(),
);
tab.local_cwd = local_cwd;
tab
}
Err(err) => {
self.status = format!("failed to split: {err:#}").into();
cx.notify();
return;
}
}
);
tab.local_cwd = local_cwd;
tab.set_text_encoding(self.config.local_terminal_encoding());
tab
}
TabKind::Ssh => {
let Some(session) = current_tab.session.clone() else {
let Some(session) = current_session else {
self.status = "cannot split: no session info".into();
cx.notify();
return;
@@ -2034,7 +2208,7 @@ impl Ashell {
TerminalTab::new_ssh(new_id.clone(), &session, backend, backend_events.clone())
}
TabKind::Serial => {
let Some(session) = current_tab.session.clone() else {
let Some(session) = current_session else {
self.status = "cannot split: no session info".into();
cx.notify();
return;
@@ -2095,7 +2269,16 @@ impl Ashell {
self.sync_sftp_to_active_tab();
self.sync_system_tab_to_active_group();
self.update_terminal_focus(previous_active_tab.as_deref());
self.status = "pane split".into();
self.status = if local_shell_fallback {
t!(
"local_terminal_shell_fallback",
terminal = local_terminal_shell_label(shell)
)
.to_string()
.into()
} else {
"pane split".into()
};
tracing::info!(
"[split] DONE: pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
self.pane_root,
@@ -2583,3 +2766,45 @@ impl Ashell {
self.update_terminal_focus(previous_active_tab.as_deref());
}
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use super::parse_local_directory_title;
#[test]
fn local_directory_title_accepts_plain_and_base64_paths() {
let path = "/tmp/ashell";
let encoded = base64::engine::general_purpose::STANDARD.encode(path);
assert_eq!(
parse_local_directory_title(&format!("ASHELL_CWD:{path}"))
.as_deref()
.and_then(|path| path.to_str()),
Some(path)
);
assert_eq!(
parse_local_directory_title(&format!("ASHELL_CWD_B64:{encoded}"))
.as_deref()
.and_then(|path| path.to_str()),
Some(path)
);
}
#[test]
fn local_directory_title_rejects_relative_paths() {
assert!(parse_local_directory_title("ASHELL_CWD:relative/path").is_none());
assert!(parse_local_directory_title("ASHELL_CWD_B64:not-base64").is_none());
}
#[cfg(windows)]
#[test]
fn local_directory_title_accepts_windows_paths() {
assert_eq!(
parse_local_directory_title(r"ASHELL_CWD:C:\Users\alice")
.and_then(|path| path.to_str().map(str::to_owned)),
Some(r"C:\Users\alice".to_string())
);
}
}
+32 -10
View File
@@ -16,7 +16,10 @@ use russh::{
client::{self, Handler},
keys::{PrivateKey, decode_secret_key, load_secret_key},
};
use russh_sftp::{client::SftpSession, protocol::FileAttributes};
use russh_sftp::{
client::SftpSession,
protocol::{FileAttributes, OpenFlags},
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::{
@@ -775,12 +778,7 @@ async fn emit_entries(
async fn connect_and_authenticate(
session: &Session,
) -> Result<Arc<russh::client::Handle<SftpClientHandler>>> {
let config = Arc::new(client::Config {
inactivity_timeout: None,
keepalive_interval: Some(std::time::Duration::from_secs(5)),
keepalive_max: 3,
..Default::default()
});
let config = Arc::new(crate::session::config::ssh_client_config());
let addr = format!("{}:{}", session.host, session.port);
let stream = crate::session::config::connect_proxy(session).await?;
let mut handle = client::connect_stream(config, stream, SftpClientHandler)
@@ -1218,9 +1216,33 @@ async fn write_text_file_impl(sftp: &SftpSession, path: &str, content: &[u8]) ->
.with_context(|| format!("preserve permissions for {path}"))?;
}
sftp.rename(temporary_path.as_str(), path)
.await
.with_context(|| format!("replace remote {path}"))
match sftp.rename(temporary_path.as_str(), path).await {
Ok(()) => Ok(()),
Err(rename_error) => {
// Some SFTP servers reject replacing an existing path with the standard
// rename request. If the original file is still present, fall back to
// truncating it in place instead of deleting it before the write succeeds.
if sftp.metadata(path).await.is_err() {
return Err(rename_error).with_context(|| format!("replace remote {path}"));
}
let mut remote_file = sftp
.open_with_flags(path, OpenFlags::WRITE | OpenFlags::TRUNCATE)
.await
.with_context(|| format!("open remote {path} for overwrite"))?;
remote_file
.write_all(content)
.await
.with_context(|| format!("write remote {path}"))?;
remote_file
.flush()
.await
.with_context(|| format!("flush remote {path}"))?;
drop(remote_file);
let _ = sftp.remove_file(temporary_path.as_str()).await;
Ok(())
}
}
}
.await;
+31 -6
View File
@@ -192,7 +192,7 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
.unwrap_or_default();
disks.push(DiskSample {
mount,
available_bytes,
available_bytes: available_bytes.min(total_bytes),
total_bytes,
});
continue;
@@ -208,13 +208,15 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
.get("CPU_PERCENT")
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.unwrap_or_default()
/ 100.0;
let mem_used = parse_u64(&kv, "MEM_USED");
let mem_total = parse_u64(&kv, "MEM_TOTAL");
let swap_used = parse_u64(&kv, "SWAP_USED");
let swap_total = parse_u64(&kv, "SWAP_TOTAL");
let mem_used = parse_u64(&kv, "MEM_USED").min(mem_total);
let swap_used = parse_u64(&kv, "SWAP_USED").min(swap_total);
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
@@ -254,7 +256,12 @@ pub fn remote_processes_from_ps(raw: &str) -> Vec<RemoteProcess> {
let mut fields = record.splitn(5, '\t');
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields.next()?.parse::<f32>().ok()?.max(0.0);
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?;
let command = fields.next()?.trim().to_string();
return (!command.is_empty()).then_some(RemoteProcess {
@@ -269,7 +276,12 @@ pub fn remote_processes_from_ps(raw: &str) -> Vec<RemoteProcess> {
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields.next()?.parse::<f32>().ok()?.max(0.0);
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?.saturating_mul(1024);
let command = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
@@ -327,7 +339,7 @@ fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
#[cfg(test)]
mod tests {
use super::{remote_ports_from_probe, remote_processes_from_ps};
use super::{remote_ports_from_probe, remote_processes_from_ps, remote_snapshot_from_kv};
#[test]
fn parses_remote_process_rows_and_rss_bytes() {
@@ -363,6 +375,19 @@ mod tests {
assert!(processes.is_empty());
}
#[test]
fn clamps_remote_resource_values_to_valid_ranges() {
let snapshot = remote_snapshot_from_kv(
"CPU_PERCENT=250\nMEM_TOTAL=100\nMEM_USED=150\nSWAP_TOTAL=0\nSWAP_USED=10\nNET_RX=12\nNET_TX=34\nDISK=/\t3145728\t2097152\n",
)
.unwrap();
assert_eq!(snapshot.cpu_percent, 1.0);
assert_eq!(snapshot.mem_percent, 1.0);
assert_eq!(snapshot.swap_percent, 0.0);
assert_eq!(snapshot.disks[0].available_bytes, 2097152);
}
#[test]
fn parses_structured_remote_port_rows() {
let ports = remote_ports_from_probe(
+32 -3
View File
@@ -146,6 +146,7 @@ struct CursorLayout {
pub struct TerminalElement {
view: Entity<Ashell>,
focus_handle: FocusHandle,
pane_focused: bool,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: SharedString,
@@ -159,6 +160,7 @@ pub struct TerminalElement {
pub(crate) struct TerminalElementConfig {
pub(crate) view: Entity<Ashell>,
pub(crate) focus_handle: FocusHandle,
pub(crate) pane_focused: bool,
pub(crate) snapshot: RenderSnapshot,
pub(crate) marked_text: Option<String>,
pub(crate) font_family: SharedString,
@@ -301,6 +303,7 @@ impl TerminalElement {
let TerminalElementConfig {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
@@ -313,6 +316,7 @@ impl TerminalElement {
Self {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
@@ -535,7 +539,15 @@ impl TerminalElement {
)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
fn cursor_layout(&self, window: &Window, cx: &App) -> Option<CursorLayout> {
if !should_render_cursor(
window.is_window_active(),
self.focus_handle.is_focused(window),
self.pane_focused,
) {
return None;
}
use crate::session::config::CursorStyle;
let cursor_style = self.view.read(cx).cursor_style;
let show_cursor = match cursor_style {
@@ -571,6 +583,10 @@ impl TerminalElement {
}
}
fn should_render_cursor(window_active: bool, terminal_focused: bool, pane_focused: bool) -> bool {
window_active && terminal_focused && pane_focused
}
impl IntoElement for TerminalElement {
type Element = Self;
@@ -610,7 +626,7 @@ impl Element for TerminalElement {
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
@@ -644,7 +660,7 @@ impl Element for TerminalElement {
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
cursor: self.cursor_layout(window, cx),
underlines,
}
}
@@ -946,3 +962,16 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
NamedColor::DimWhite => Hsla::from(rgb(0xe5e9f0)),
}
}
#[cfg(test)]
mod tests {
use super::should_render_cursor;
#[test]
fn renders_cursor_only_for_focused_pane_in_focused_terminal_window() {
assert!(should_render_cursor(true, true, true));
assert!(!should_render_cursor(false, true, true));
assert!(!should_render_cursor(true, false, true));
assert!(!should_render_cursor(true, true, false));
}
}
+190 -15
View File
@@ -164,17 +164,86 @@ fn highlight_colors() -> HighlightColors {
}
}
#[derive(Clone, Copy)]
struct KeywordMatch {
start_col: i32,
end_col: i32,
color: Hsla,
priority: usize,
}
impl KeywordMatch {
fn span_len(self) -> i32 {
self.end_col - self.start_col + 1
}
fn overlaps(self, other: Self) -> bool {
self.start_col <= other.end_col && other.start_col <= self.end_col
}
}
#[derive(Default)]
struct KeywordHighlights {
colors: HashMap<(i32, i32), Hsla>,
pending: Vec<KeywordMatch>,
next_priority: usize,
}
impl KeywordHighlights {
fn apply_row(&mut self, row_i32: i32) {
let mut matches = std::mem::take(&mut self.pending);
matches.sort_unstable_by(|left, right| {
right
.span_len()
.cmp(&left.span_len())
.then(left.priority.cmp(&right.priority))
.then(left.start_col.cmp(&right.start_col))
});
let mut selected = Vec::with_capacity(matches.len());
for candidate in matches {
if selected
.iter()
.copied()
.any(|existing| candidate.overlaps(existing))
{
continue;
}
selected.push(candidate);
}
for candidate in selected {
for col in candidate.start_col..=candidate.end_col {
self.colors.insert((row_i32, col), candidate.color);
}
}
}
}
fn is_keyword_word_char(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn has_keyword_boundaries(text: &[u8], start: usize, end: usize) -> bool {
(start == 0 || !is_keyword_word_char(text[start - 1]))
&& (end == text.len() || !is_keyword_word_char(text[end]))
}
/// Highlight all occurrences of keyword list in `text`, writing to `map`.
/// Case-insensitive, matches inside larger words (e.g. "my_ERROR" highlights "ERROR").
/// Each keyword only matches once per position (no overlapping highlights).
/// Case-insensitive, matches only complete words or phrases.
/// Each keyword only matches once per position; overlapping matches are resolved after the row
/// has been scanned so a longer keyword keeps the entire word in one color.
fn highlight_keywords(
map: &mut HashMap<(i32, i32), Hsla>,
map: &mut KeywordHighlights,
text: &str,
byte_to_col: &[i32],
row_i32: i32,
_row_i32: i32,
keywords: &[&str],
color: Hsla,
) {
let priority = map.next_priority;
map.next_priority += 1;
for &kw in keywords {
let kw_lower: Vec<u8> = kw.bytes().map(|b| b.to_ascii_lowercase()).collect();
let text_bytes = text.as_bytes();
@@ -183,11 +252,19 @@ fn highlight_keywords(
while start + kw_lower.len() <= text_lower.len() {
if text_lower[start..].starts_with(&kw_lower) {
let abs = start;
let end = abs + kw.len();
if !has_keyword_boundaries(text_bytes, abs, end) {
start = end;
continue;
}
let start_col = byte_to_col[abs];
let end_col = byte_to_col[(abs + kw.len() - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(color);
}
map.pending.push(KeywordMatch {
start_col,
end_col,
color,
priority,
});
start = abs + kw.len();
} else {
start += 1;
@@ -294,7 +371,7 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
row.sort_by_key(|&(col, _)| col);
}
let mut map = HashMap::new();
let mut map = KeywordHighlights::default();
let mut chars_buf = String::with_capacity(128);
let mut byte_to_col: Vec<i32> = Vec::with_capacity(128);
@@ -772,8 +849,10 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
colors.deprecated,
);
map.apply_row(row_i32);
// ── 30. HTTP status codes ──────────────────────────────
highlight_http_codes(&mut map, text, &byte_to_col, row_i32, &colors);
highlight_http_codes(&mut map.colors, text, &byte_to_col, row_i32, &colors);
// ── 31. IP addresses ───────────────────────────────────
for m in find_ip_addresses(text) {
@@ -781,7 +860,7 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + ip_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.network);
map.colors.entry((row_i32, c)).or_insert(colors.network);
}
}
@@ -791,7 +870,7 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + port_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.port);
map.colors.entry((row_i32, c)).or_insert(colors.port);
}
}
}
@@ -805,13 +884,13 @@ pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32),
let idx = m + i;
if idx < line.byte_to_cell.len() {
let (r, c) = line.byte_to_cell[idx];
map.entry((r as i32, c as i32)).or_insert(colors.url);
map.colors.entry((r as i32, c as i32)).or_insert(colors.url);
}
}
}
}
map
map.colors
}
fn find_ip_len(text: &str) -> usize {
@@ -888,8 +967,30 @@ fn find_urls(text: &str) -> Vec<usize> {
}
fn find_url_len(text: &str) -> usize {
text.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(text.len())
let end = text
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(text.len());
let mut url = &text[..end];
loop {
let Some(&closing) = url.as_bytes().last() else {
break;
};
let opening = match closing {
b')' => b'(',
b']' => b'[',
b'}' => b'{',
_ => break,
};
let opening_count = url.bytes().filter(|byte| *byte == opening).count();
let closing_count = url.bytes().filter(|byte| *byte == closing).count();
if closing_count <= opening_count {
break;
}
url = &url[..url.len() - 1];
}
url.len()
}
fn find_ports(text: &str) -> Vec<usize> {
@@ -1038,3 +1139,77 @@ pub fn find_url_at_cell(
}
None
}
#[cfg(test)]
mod tests {
use super::{KeywordHighlights, KeywordMatch, find_url_len, hsla};
fn detected_url(text: &str) -> &str {
&text[..find_url_len(text)]
}
#[test]
fn excludes_unmatched_trailing_parenthesis() {
assert_eq!(
detected_url("https://onlyoffice.com/desktop)"),
"https://onlyoffice.com/desktop"
);
}
#[test]
fn preserves_balanced_parentheses_in_url() {
assert_eq!(
detected_url("https://example.com/wiki/Function_(mathematics)"),
"https://example.com/wiki/Function_(mathematics)"
);
}
#[test]
fn removes_only_unmatched_trailing_delimiters() {
assert_eq!(
detected_url("https://example.com/path_(one))] following text"),
"https://example.com/path_(one)"
);
}
#[test]
fn overlapping_keywords_keep_the_longest_match_color() {
let long_color = hsla(10, 20, 30);
let short_color = hsla(200, 210, 220);
let mut highlights = KeywordHighlights::default();
highlights.pending = vec![
KeywordMatch {
start_col: 3,
end_col: 11,
color: short_color,
priority: 0,
},
KeywordMatch {
start_col: 0,
end_col: 11,
color: long_color,
priority: 1,
},
];
highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 12);
assert!(highlights.colors.values().all(|color| *color == long_color));
}
#[test]
fn keyword_matches_require_word_boundaries() {
let text = "Bootstrap BOOT";
let byte_to_col = (0..text.len()).map(|col| col as i32).collect::<Vec<_>>();
let color = hsla(10, 20, 30);
let mut highlights = KeywordHighlights::default();
super::highlight_keywords(&mut highlights, text, &byte_to_col, 0, &["BOOT"], color);
highlights.apply_row(0);
assert_eq!(highlights.colors.len(), 4);
assert!((0..9).all(|col| !highlights.colors.contains_key(&(0, col))));
assert!((10..14).all(|col| highlights.colors.get(&(0, col)) == Some(&color)));
}
}
+104 -1
View File
@@ -778,7 +778,7 @@ pub enum BackendTx {
Local(Sender<BackendCommand>),
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
Serial(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
/// A restored session that is waiting for the user to confirm reconnecting.
/// A restored session that is waiting for its backend to be started.
Pending,
}
@@ -822,6 +822,7 @@ pub struct TerminalTab {
pub rows: u16,
pub backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
backend_events: GuardedBackendEventSender,
should_cleanup_initial_blank_scrollback: bool,
pub scroll_pixel_y: f32,
pub(crate) highlight_cache: HighlightCache,
}
@@ -1083,6 +1084,68 @@ mod backend_event_tests {
}
}
#[cfg(test)]
mod terminal_tab_backend_tests {
use super::{BackendTx, GuardedBackendEventSender, TerminalTab};
#[test]
fn pending_backend_starts_only_before_a_disconnect_or_backend_swap() {
let (events_tx, _events_rx) = std::sync::mpsc::channel();
let mut tab = TerminalTab::new_local(
"tab-1".into(),
"Local".into(),
BackendTx::Pending,
GuardedBackendEventSender::new(events_tx),
);
assert!(tab.backend_start_pending());
tab.disconnected_reason = Some("startup failed".into());
assert!(!tab.backend_start_pending());
tab.disconnected_reason = None;
let (backend_tx, _backend_rx) = std::sync::mpsc::channel();
tab.set_backend(BackendTx::Local(backend_tx));
assert!(!tab.backend_start_pending());
}
#[test]
fn clears_blank_scrollback_created_during_local_terminal_startup() {
let (events_tx, _events_rx) = std::sync::mpsc::channel();
let mut tab = TerminalTab::new_local(
"tab-1".into(),
"Local".into(),
BackendTx::Pending,
GuardedBackendEventSender::new(events_tx),
);
tab.should_cleanup_initial_blank_scrollback = true;
tab.resize(10, 2);
tab.feed(b"\r\n\r\n\r\n");
assert_eq!(tab.render_snapshot(false).history_size, 0);
assert!(tab.should_cleanup_initial_blank_scrollback);
}
#[test]
fn preserves_non_blank_scrollback_created_during_local_terminal_startup() {
let (events_tx, _events_rx) = std::sync::mpsc::channel();
let mut tab = TerminalTab::new_local(
"tab-1".into(),
"Local".into(),
BackendTx::Pending,
GuardedBackendEventSender::new(events_tx),
);
tab.should_cleanup_initial_blank_scrollback = true;
tab.resize(10, 2);
tab.feed(b"first\r\nsecond\r\nthird");
assert!(tab.render_snapshot(false).history_size > 0);
assert!(!tab.should_cleanup_initial_blank_scrollback);
}
}
#[cfg(test)]
mod osc_terminal_tests {
use std::{
@@ -1494,6 +1557,7 @@ impl TerminalTab {
rows: 30,
backend: shared_backend,
backend_events,
should_cleanup_initial_blank_scrollback: cfg!(windows) && kind == TabKind::Local,
scroll_pixel_y: 0.0,
highlight_cache: std::cell::RefCell::new(None),
}
@@ -1527,9 +1591,33 @@ impl TerminalTab {
}
}
self.processor.advance(&mut self.term, &decoded);
self.cleanup_initial_blank_scrollback();
notifications
}
/// Drops ConPTY startup artifacts without removing real terminal output.
fn cleanup_initial_blank_scrollback(&mut self) -> bool {
if !self.should_cleanup_initial_blank_scrollback {
return false;
}
let history_size = self.term.grid().history_size();
if history_size == 0 {
return false;
}
let history_is_blank =
(1..=history_size).all(|offset| self.term.grid()[Line(-(offset as i32))].is_clear());
if !history_is_blank {
self.should_cleanup_initial_blank_scrollback = false;
return false;
}
self.term.grid_mut().clear_history();
self.scroll_pixel_y = 0.0;
true
}
pub(crate) fn is_command_active(&self) -> bool {
(self.command_running && !self.is_alternate_screen_active())
|| self.output_activity_until.is_some()
@@ -1614,6 +1702,20 @@ impl TerminalTab {
}
}
/// Returns whether this tab is waiting for its first backend start.
pub(crate) fn backend_start_pending(&self) -> bool {
self.disconnected_reason.is_none()
&& self
.backend
.lock()
.is_ok_and(|backend| matches!(*backend, BackendTx::Pending))
}
/// Clones the guarded event sender for a backend started after layout.
pub(crate) fn backend_events(&self) -> GuardedBackendEventSender {
self.backend_events.clone()
}
/// Advances this tab to a new backend generation and returns its sender.
/// Call this before closing the old backend so all of its remaining events
/// are discarded immediately.
@@ -1635,6 +1737,7 @@ impl TerminalTab {
self.rows
);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.cleanup_initial_blank_scrollback();
self.send_backend(BackendCommand::Resize { cols, rows });
true
} else {