diff --git a/CHANGELOG.md b/CHANGELOG.md index 15dc62d..1213ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 文件列表面板的展开/折叠按钮补充明确文字,并随面板状态同步切换。 ### 兼容性与稳定性 diff --git a/Cargo.toml b/Cargo.toml index cd7b40e..00a4a79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", ] } diff --git a/locales/en.yml b/locales/en.yml index 668aea7..461ccc3 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 3ffc4af..a6bbd13 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -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: "退出应用" diff --git a/src/app/controls.rs b/src/app/controls.rs index 98f02a7..ecdf178 100644 --- a/src/app/controls.rs +++ b/src/app/controls.rs @@ -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) -> 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) -> 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(); diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index b8c7a9e..4a3daf6 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -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")), ) diff --git a/src/app/mod.rs b/src/app/mod.rs index 6710eb7..b883c2c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, pub(crate) unread_terminal_notifications: HashSet, pub(crate) prev_monitoring_size: Option, @@ -402,7 +404,6 @@ pub(crate) struct Ashell { pub(crate) cmd_ctrl_pressed: bool, pub(crate) _subscriptions: Vec, pub(crate) last_window_bounds: Option, - pub(crate) window_bounds_save_task: Option>, pub(crate) save_lock: std::sync::Arc>, pub(crate) save_latest_seq: std::sync::Arc, } @@ -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) -> 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); }); diff --git a/src/app/search.rs b/src/app/search.rs index d9bbe87..9bf10e3 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -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) { @@ -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); diff --git a/src/app/ui.rs b/src/app/ui.rs index 10b4834..0dbd54e 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -9,7 +9,7 @@ use gpui::{ Anchor, AppContext as _, Context, DismissEvent, ElementId, Empty, Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, PathBuilder, Pixels, Render, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, - hsla, point, prelude::FluentBuilder as _, px, relative, rems, uniform_list, + hsla, point, prelude::FluentBuilder as _, px, relative, uniform_list, }; use gpui_component::{ ActiveTheme, Disableable as _, ElementExt, Icon, IconName, InteractiveElementExt as _, Root, @@ -33,7 +33,7 @@ use crate::{ constants::{SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT, TERMINAL_SCROLLBAR_GUTTER}, controls::{ PointerClipboard, PointerSelectionCheckbox, SelectionState, pointer_button, - pointer_checkbox, + pointer_checkbox, ui_rems, }, }, sftp::format_mtime, @@ -117,6 +117,7 @@ fn compact_menu_width(labels: &[&str]) -> Pixels { } const TAB_SCROLL_ANIMATION_DURATION: Duration = Duration::from_millis(180); +const TAB_SCROLL_LAYOUT_RETRY_FRAMES: u8 = 3; impl Ashell { fn tab_scroll_target_x(&self, index: usize) -> Option { @@ -183,11 +184,47 @@ impl Ashell { }); } - fn ensure_tab_visible(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + pub(crate) fn ensure_tab_visible( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { self.tab_scroll_animation_id = self.tab_scroll_animation_id.wrapping_add(1); let animation_id = self.tab_scroll_animation_id; + self.ensure_tab_visible_after_layout( + index, + animation_id, + TAB_SCROLL_LAYOUT_RETRY_FRAMES, + window, + cx, + ); + } + + fn ensure_tab_visible_after_layout( + &mut self, + index: usize, + animation_id: u64, + retries_remaining: u8, + window: &mut Window, + cx: &mut Context, + ) { cx.on_next_frame(window, move |this, window, cx| { + if this.tab_scroll_animation_id != animation_id { + return; + } + let Some(target_x) = this.tab_scroll_target_x(index) else { + if retries_remaining > 0 { + cx.notify(); + this.ensure_tab_visible_after_layout( + index, + animation_id, + retries_remaining - 1, + window, + cx, + ); + } return; }; let start_x = this.tabs_scroll_handle.offset().x; @@ -261,13 +298,13 @@ impl Ashell { ) .child( div() - .text_size(rems(1.5)) + .text_size(ui_rems(1.5)) .font_weight(FontWeight::SEMIBOLD) .child("Ashell"), ) .child( div() - .text_size(rems(1.083)) + .text_size(ui_rems(1.083)) .text_color(cx.theme().muted_foreground) .child(t!("open_local_or_ssh")), ) @@ -347,7 +384,6 @@ impl Ashell { ) -> impl IntoElement { pointer_button(id) .ghost() - .small() .icon(IconName::ChevronsUpDown) .label(t!("transfers").to_string()) .tooltip(t!("transfers").to_string()) @@ -379,7 +415,6 @@ impl Ashell { .trigger( pointer_button(trigger_id) .ghost() - .small() .icon(IconName::Menu) .label(t!("command_history_short").to_string()) .tooltip(t!("command_history").to_string()), @@ -405,7 +440,6 @@ impl Ashell { this.child( pointer_button(id) .ghost() - .small() .icon(IconName::Globe) .label(encoding.label()) .tooltip(t!("terminal_encoding").to_string()) @@ -463,8 +497,8 @@ impl Ashell { .child( pointer_button("sftp-minimize-toggle") .ghost() - .small() .icon(IconName::ChevronUp) + .label(t!("panel_expand_short").to_string()) .tooltip(t!("panel_expand").to_string()) .on_click(cx.listener(|this, _, window, cx| { this.toggle_sftp_minimized(window, cx); @@ -508,7 +542,6 @@ impl Ashell { this.child( pointer_button("sftp-header-more") .ghost() - .small() .icon(IconName::Ellipsis) .label(t!("sftp_more_actions_short").to_string()) .tooltip(t!("sftp_more_actions").to_string()) @@ -558,8 +591,8 @@ impl Ashell { .child( pointer_button("sftp-minimize-toggle-header") .ghost() - .small() .icon(IconName::ChevronDown) + .label(t!("panel_minimize_short").to_string()) .tooltip(t!("panel_minimize").to_string()) .on_click(cx.listener(|this, _, window, cx| { this.toggle_sftp_minimized(window, cx); @@ -587,7 +620,7 @@ impl Ashell { .p_3() .child( div() - .text_size(rems(1.0)) + .text_size(ui_rems(1.0)) .text_color(cx.theme().muted_foreground) .child(t!("open_ssh_tab_sftp")), ), @@ -717,7 +750,7 @@ impl Ashell { .flex_1() .min_w(px(0.)) .truncate() - .text_size(rems(0.917)) + .text_size(ui_rems(0.917)) .text_color(cx.theme().muted_foreground) .child(t!("name")), ), @@ -734,7 +767,7 @@ impl Ashell { .size_full() .items_center() .px_2() - .text_size(rems(0.917)) + .text_size(ui_rems(0.917)) .text_color(cx.theme().muted_foreground) .child(t!("size")), ), @@ -751,7 +784,7 @@ impl Ashell { .size_full() .items_center() .px_2() - .text_size(rems(0.917)) + .text_size(ui_rems(0.917)) .text_color(cx.theme().muted_foreground) .child(t!("modified")), ), @@ -797,7 +830,7 @@ impl Ashell { .child( pointer_button("sftp-up") .ghost() - .small() + .icon(IconName::ChevronUp) .tooltip(t!("parent_directory").to_string()) .on_click(cx.listener(move |this, _, _, cx| { @@ -813,7 +846,7 @@ impl Ashell { .child( pointer_button("sftp-sync-cwd") .ghost() - .small() + .icon(IconName::Replace) .label(t!("sync_cwd").to_string()) .tooltip(t!("sync_cwd_tooltip").to_string()) @@ -824,7 +857,7 @@ impl Ashell { .child( pointer_button("sftp-refresh") .ghost() - .small() + .icon(IconName::Redo) .label(t!("refresh").to_string()) .tooltip(t!("refresh").to_string()) @@ -833,7 +866,7 @@ impl Ashell { .child( pointer_button("sftp-create-upload") .ghost() - .small() + .icon(IconName::Plus) .label(t!("add").to_string()) .tooltip(t!("create_or_upload").to_string()) @@ -923,7 +956,7 @@ impl Ashell { .border_b_1() .border_color(cx.theme().border) .bg(cx.theme().muted.opacity(0.8)) - .text_size(rems(0.917)) + .text_size(ui_rems(0.917)) .text_color(cx.theme().muted_foreground) .child(t!("directories")), ) @@ -1003,7 +1036,7 @@ impl Ashell { ix, )) .ghost() - .xsmall() + .small() .icon(if row.expanded { IconName::ChevronDown } else { @@ -1083,7 +1116,7 @@ impl Ashell { .min_w(px(0.)) .truncate() .text_size( - rems(0.917), + ui_rems(0.917), ) .child(row.label), ) @@ -1147,7 +1180,7 @@ impl Ashell { .child( pointer_button("cancel-sftp-new-folder") .ghost() - .xsmall() + .small() .icon(IconName::Close) .tooltip(t!("cancel").to_string()) .on_click(cx.listener( @@ -1279,7 +1312,7 @@ impl Ashell { .flex_1() .min_w(px(0.)) .truncate() - .text_size(rems(0.917)) + .text_size(ui_rems(0.917)) .text_color(cx.theme().muted_foreground) .child( t!( @@ -1292,8 +1325,9 @@ impl Ashell { .child( pointer_button("sftp-download-selected") .ghost() - .xsmall() + .small() .icon(IconName::ArrowDown) + .label(t!("download").to_string()) .tooltip( t!( "download_count", @@ -1312,8 +1346,9 @@ impl Ashell { .child( pointer_button("sftp-delete-selected") .danger() - .xsmall() + .small() .icon(IconName::Delete) + .label(t!("delete_selected").to_string()) .tooltip(t!("delete_selected").to_string()) .on_click(cx.listener( |this, _, window, cx| { @@ -1609,7 +1644,7 @@ impl Ashell { } }) .text_size( - rems(1.0), + ui_rems(1.0), ) .text_color( name_color, @@ -1633,7 +1668,7 @@ impl Ashell { .items_center() .px_2() .text_size( - rems(0.917), + ui_rems(0.917), ) .text_color( theme @@ -1668,7 +1703,7 @@ impl Ashell { .items_center() .px_2() .text_size( - rems(0.917), + ui_rems(0.917), ) .text_color( theme @@ -1716,7 +1751,7 @@ impl Ashell { .items_center() .justify_center() .px_4() - .text_size(rems(0.917)) + .text_size(ui_rems(0.917)) .text_color( cx.theme().muted_foreground, ) @@ -1884,7 +1919,7 @@ impl Ashell { .items_center() .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .text_color(cpu_color) .child(t!("cpu").to_string()), @@ -1892,7 +1927,7 @@ impl Ashell { .child(div().flex_1()) .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(muted_fg) .child(format!("{:.0}%", cpu_pct * 100.0)), ), @@ -1960,7 +1995,7 @@ impl Ashell { .items_center() .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .text_color(mem_color) .child(t!("mem").to_string()), @@ -1968,7 +2003,7 @@ impl Ashell { .child(div().flex_1()) .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(muted_fg) .child(format!("{:.0}%", mem_pct * 100.0)), ), @@ -1987,7 +2022,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.7)) + .text_size(ui_rems(0.7)) .text_color(muted_fg) .child(mem_detail), ), @@ -2007,7 +2042,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.7)) + .text_size(ui_rems(0.7)) .text_color(muted_fg) .child(swap_detail), ), @@ -2042,7 +2077,7 @@ impl Ashell { .items_center() .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .text_color(net_color) .child(t!("net").to_string()), @@ -2053,13 +2088,13 @@ impl Ashell { .gap_1() .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(net_color) .child(format!("↓{}", net_rx)), ) .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(net_tx_color) .child(format!("↑{}", net_tx)), ), @@ -2149,7 +2184,7 @@ impl Ashell { .items_center() .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .text_color(disk_color) .child(t!("disk").to_string()), @@ -2157,7 +2192,7 @@ impl Ashell { .child(div().flex_1()) .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(muted_fg) .child(format!("{:.0}%", disk_pct)), ), @@ -2189,7 +2224,7 @@ impl Ashell { .gap_1() .child( div() - .text_size(rems(0.667)) + .text_size(ui_rems(0.667)) .text_color(muted_fg) .child(mount_short), ) @@ -2202,7 +2237,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.667)) + .text_size(ui_rems(0.667)) .text_color(muted_fg) .child(format!("{:.0}%", pct)), ) @@ -2303,7 +2338,7 @@ impl Ashell { div() .min_w(px(0.)) .truncate() - .text_size(rems(0.8)) + .text_size(ui_rems(0.8)) .font_weight(FontWeight::SEMIBOLD) .child(monitor_title), ) @@ -2311,7 +2346,7 @@ impl Ashell { div() .min_w(px(0.)) .truncate() - .text_size(rems(0.7)) + .text_size(ui_rems(0.7)) .text_color(muted_fg) .child(monitor_detail), ), @@ -2319,7 +2354,7 @@ impl Ashell { .child( div() .flex_none() - .text_size(rems(0.7)) + .text_size(ui_rems(0.7)) .text_color(muted_fg) .child(t!("system_info")), ), @@ -2348,13 +2383,13 @@ impl Ashell { .justify_between() .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(cpu_color) .child(t!("cpu").to_string()), ) .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(muted_fg) .child(format!("{:.1}%", cpu_pct * 100.0)), ), @@ -2391,13 +2426,13 @@ impl Ashell { .justify_between() .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(mem_color) .child(t!("mem").to_string()), ) .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(muted_fg) .child(self.system.mem_detail.clone()), ), @@ -2418,13 +2453,13 @@ impl Ashell { .justify_between() .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(swap_color) .child(t!("swap").to_string()), ) .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(muted_fg) .child(self.system.swap_detail.clone()), ), @@ -2446,14 +2481,14 @@ impl Ashell { .items_center() .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(disk_color) .child(t!("disk").to_string()), ) .children(if self.system.disks.len() > 3 { Some( div() - .text_size(rems(0.65)) + .text_size(ui_rems(0.65)) .text_color(muted_fg) .child(t!("scroll").to_string()), ) @@ -2489,13 +2524,13 @@ impl Ashell { .justify_between() .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(muted_fg) .child(mount_short), ) .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(muted_fg) .child(format!("{:.1}%", pct)), ), @@ -2543,13 +2578,13 @@ impl Ashell { .justify_between() .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(net_color) .child(t!("net").to_string()), ) .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(muted_fg) .child(t!("live")), ), @@ -2565,13 +2600,13 @@ impl Ashell { .child( div() .flex_none() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(net_color) .child("↓"), ) .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .child(self.system.net_rx.clone()), ), ) @@ -2583,13 +2618,13 @@ impl Ashell { .child( div() .flex_none() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(cx.theme().chart_5) .child("↑"), ) .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .child(self.system.net_tx.clone()), ), ), @@ -2655,7 +2690,7 @@ impl Ashell { div() .flex_1() .min_w(px(0.)) - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .font_weight(FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) .child(t!("process").to_string()), @@ -2664,7 +2699,7 @@ impl Ashell { div() .w(metric_column_width) .flex_none() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_right() .text_color(cx.theme().muted_foreground) .child(metric_label), @@ -2682,7 +2717,7 @@ impl Ashell { .child( pointer_button("refresh-processes") .ghost() - .xsmall() + .small() .icon(IconName::Redo) .tooltip(t!("refresh_processes").to_string()) .disabled(self.remote_processes_in_flight) @@ -2712,7 +2747,7 @@ impl Ashell { .px_2() .py_1() .truncate() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(cx.theme().muted_foreground) .child(status) })) @@ -2725,7 +2760,7 @@ impl Ashell { .when(processes.is_empty() && !processes_loading, |this| { this.flex().items_center().justify_center().child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(theme.muted_foreground) .child(empty_message.clone()), ) @@ -2802,14 +2837,14 @@ impl Ashell { div() .w_full() .truncate() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .child(command.clone()), ) .child( div() .w_full() .truncate() - .text_size(rems(0.667)) + .text_size(ui_rems(0.667)) .text_color( row_theme .muted_foreground, @@ -2827,7 +2862,7 @@ impl Ashell { .when(expanded, |this| { this.child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color( row_theme .muted_foreground, @@ -2843,7 +2878,7 @@ impl Ashell { div() .w(metric_column_width) .flex_none() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .font_weight(FontWeight::SEMIBOLD) .text_right() .text_color( @@ -2902,7 +2937,7 @@ impl Ashell { )) .danger() .outline() - .xsmall() + .small() .icon(IconName::Delete) .tooltip( t!("terminate_process") @@ -2941,7 +2976,7 @@ impl Ashell { .w_full() .min_w(px(0.)) .whitespace_normal() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .child(format!( "{}: {}", t!("process_command"), @@ -2951,7 +2986,7 @@ impl Ashell { .child( h_flex() .gap_3() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(row_theme.muted_foreground) .child(format!( "{}: {}", @@ -3038,7 +3073,7 @@ impl Ashell { .child( div() .flex_1() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .font_weight(FontWeight::SEMIBOLD) .text_color(theme.muted_foreground) .child(t!("network_ports").to_string()), @@ -3046,7 +3081,7 @@ impl Ashell { .child( pointer_button("refresh-ports") .ghost() - .xsmall() + .small() .icon(IconName::Redo) .tooltip(t!("refresh_ports").to_string()) .disabled(loading) @@ -3070,7 +3105,7 @@ impl Ashell { .items_center() .gap_2() .px_2() - .text_size(rems(0.667)) + .text_size(ui_rems(0.667)) .text_color(theme.muted_foreground) .child( div() @@ -3100,7 +3135,7 @@ impl Ashell { .px_2() .py_1() .truncate() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(theme.muted_foreground) .child(status) })) @@ -3113,7 +3148,7 @@ impl Ashell { .when(ports.is_empty() && !loading, |this| { this.flex().items_center().justify_center().child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(theme.muted_foreground) .child(empty_message.clone()), ) @@ -3147,7 +3182,7 @@ impl Ashell { div() .w(px(64.)) .flex_none() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .child(port.protocol.clone()), ) .child( @@ -3155,14 +3190,14 @@ impl Ashell { .flex_1() .min_w(px(0.)) .truncate() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .child(port.address.clone()), ) .child( div() .w(px(60.)) .flex_none() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .child(port.port.to_string()), ) .child( @@ -3170,7 +3205,7 @@ impl Ashell { .w(px(82.)) .flex_none() .truncate() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color( theme.muted_foreground, ) @@ -3181,7 +3216,7 @@ impl Ashell { .w(px(150.)) .flex_none() .truncate() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color( theme.muted_foreground, ) @@ -3380,7 +3415,6 @@ impl Ashell { pointer_checkbox(ElementId::Name( format!("connection-check-{selection_id}").into(), )) - .small() .checked(is_selected) .tab_stop(false) .on_click(cx.listener({ @@ -3401,7 +3435,7 @@ impl Ashell { .flex_basis(relative(0.5)) .min_w(px(0.)) .truncate() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .child(name), ) @@ -3412,7 +3446,7 @@ impl Ashell { .min_w(px(0.)) .truncate() .text_right() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(cx.theme().muted_foreground) .child(detail), ) @@ -3432,11 +3466,11 @@ impl Ashell { .child( pointer_button(connect_button_id) .ghost() - .xsmall() + .small() .icon(IconName::ExternalLink) .tooltip(t!("connect").to_string()) - .on_click(cx.listener(move |this, _, _, cx| { - this.connect_saved_session(connect_id.clone(), cx); + .on_click(cx.listener(move |this, _, window, cx| { + this.connect_saved_session(connect_id.clone(), window, cx); })), ), ), @@ -3549,14 +3583,14 @@ impl Ashell { .flex_1() .min_w(px(0.)) .truncate() - .text_size(rems(0.8)) + .text_size(ui_rems(0.8)) .font_weight(FontWeight::SEMIBOLD) .child(display_name), ) .child( div() .flex_none() - .text_size(rems(0.7)) + .text_size(ui_rems(0.7)) .text_color(cx.theme().muted_foreground) .child(count.to_string()), ) @@ -3574,7 +3608,7 @@ impl Ashell { format!("connection-group-menu-{group_id}").into(), )) .ghost() - .xsmall() + .small() .icon(IconName::Ellipsis) .tooltip(t!("more").to_string()) .dropdown_menu_with_anchor(Anchor::BottomRight, { @@ -3701,7 +3735,7 @@ impl Ashell { div() .flex_1() .min_w(px(0.)) - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .text_color(cx.theme().foreground) .truncate() @@ -3710,7 +3744,7 @@ impl Ashell { .child( pointer_button("import-connections") .ghost() - .xsmall() + .small() .icon(IconName::ArrowDown) .label(t!("import_connections").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -3720,7 +3754,7 @@ impl Ashell { .child( pointer_button("export-connections") .ghost() - .xsmall() + .small() .icon(IconName::ArrowUp) .label(t!("export_connections").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -3730,7 +3764,7 @@ impl Ashell { .child( pointer_button("open-ssh-panel") .primary() - .xsmall() + .small() .icon(IconName::Plus) .label(t!("new_connection_short").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -3758,7 +3792,6 @@ impl Ashell { .justify_center() .child( pointer_checkbox("connections-select-all") - .small() .checked(all_connections_selected) .disabled(!has_connections) .tab_stop(false) @@ -3777,7 +3810,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(cx.theme().muted_foreground) .child(format!("{selected_connections}/{total_connections}")), ) @@ -3785,8 +3818,8 @@ impl Ashell { .child( pointer_button("new-connection-group") .ghost() - .small() .icon(IconName::Plus) + .label(t!("new_connection_short").to_string()) .tooltip(t!("new_connection_group").to_string()) .on_click(cx.listener(|this, _, window, cx| { this.show_connection_group_dialog(None, window, cx); @@ -3796,8 +3829,8 @@ impl Ashell { let groups = connection_groups.clone(); pointer_button("move-selected-connections") .ghost() - .small() .icon(IconName::FolderClosed) + .label(t!("move_to_group").to_string()) .tooltip(t!("move_to_group").to_string()) .disabled(!has_selected_connections) .dropdown_menu_with_anchor(Anchor::BottomRight, { @@ -3836,7 +3869,6 @@ impl Ashell { .child( pointer_button("delete-selected-connections") .danger() - .small() .icon(IconName::Delete) .label(t!("delete_selected_connections").to_string()) .disabled(!has_selected_connections) @@ -3870,7 +3902,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(cx.theme().muted_foreground) .child(empty_connections_message.clone()), ) @@ -3878,7 +3910,6 @@ impl Ashell { this.child( pointer_button("empty-new-connection") .secondary() - .small() .icon(IconName::Plus) .label(t!("new_connection").to_string()) .on_click(cx.listener( @@ -4104,21 +4135,27 @@ impl Ashell { let tabbar_menu = { let view = view.clone(); let tab_entries = groups_data.clone(); + let active_group = self.active_group.clone(); + let active_tab = self.active_tab.clone(); h_flex().flex_none().child( pointer_button("tabbar-menu") .ghost() - .small() .icon(IconName::ChevronDown) .tooltip(t!("settings_tab_list").to_string()) .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, window, menu_cx| { let popup_menu = menu_cx.entity(); tab_entries.iter().enumerate().fold( menu.scrollable(true), - |menu, (ix, (group_id, label, _))| { + |menu, (ix, (group_id, label, pane_ids))| { let group_id = group_id.clone(); let drag_group_id = group_id.clone(); let target_group_id = group_id.clone(); let target_group_for_style = group_id.clone(); + let close_tab_id = if active_group.as_ref() == Some(&group_id) { + active_tab.clone().or_else(|| pane_ids.first().cloned()) + } else { + pane_ids.first().cloned() + }; let item_view = view.clone(); let item_menu = popup_menu.clone(); let label = label.clone(); @@ -4129,6 +4166,9 @@ impl Ashell { let drop_view = item_view.clone(); let drop_menu = item_menu.clone(); let drop_target = target_group_id.clone(); + let close_view = item_view.clone(); + let close_menu = item_menu.clone(); + let close_tab_id = close_tab_id.clone(); h_flex() .flex_1() .min_w(px(0.)) @@ -4169,7 +4209,38 @@ impl Ashell { cx.emit(DismissEvent); }); }) - .child(label.clone()) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .child(label.clone()), + ) + .child( + pointer_button(("tab-group-close", ix)) + .ghost() + .icon(IconName::Delete) + .tooltip(t!("delete").to_string()) + .on_mouse_down( + MouseButton::Left, + |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }, + ) + .on_click(move |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + if let Some(tab_id) = close_tab_id.clone() { + close_view.update(cx, |this, cx| { + this.close_tab(tab_id, cx); + }); + } + close_menu.update(cx, |_, cx| { + cx.emit(DismissEvent); + }); + }), + ) }) .checked(ix == selected) .on_click( @@ -4194,7 +4265,6 @@ impl Ashell { .child( pointer_button("sidebar-toggle") .ghost() - .small() .icon(if self.sidebar_collapsed { IconName::PanelLeftOpen } else { @@ -4276,7 +4346,7 @@ impl Ashell { .id(("ashell-tab", ix)) .relative() .flex_none() - .h(px(30.)) + .h(px(34.)) .min_w(px(112.)) .max_w(px(220.)) .border_b_2() @@ -4285,7 +4355,7 @@ impl Ashell { } else { cx.theme().transparent }) - .text_size(rems(0.875)) + .text_size(ui_rems(0.875)) .hover(|this| { this.text_color( cx.theme().tab_active_foreground, @@ -4308,7 +4378,7 @@ impl Ashell { .when(output_active, |this| { this.child( Spinner::new() - .xsmall() + .small() .color( cx.theme().primary, ), @@ -4346,7 +4416,7 @@ impl Ashell { .child( pointer_button(("tab-close", ix)) .ghost() - .xsmall() + .small() .icon(IconName::Close) .opacity(if ix == selected { 0.8 @@ -4376,7 +4446,6 @@ impl Ashell { ), ) .cursor_pointer() - .block_mouse_except_scroll() .on_mouse_down(MouseButton::Left, |_, window, _| { window.prevent_default(); }) @@ -4412,7 +4481,6 @@ impl Ashell { .child( pointer_button("open-selector") .ghost() - .small() .icon(IconName::Plus) .tooltip(t!("settings_open_session").to_string()) .dropdown_menu_with_anchor(Anchor::BottomRight, { @@ -4443,7 +4511,6 @@ impl Ashell { .child( pointer_button("split-horizontal") .ghost() - .small() .icon(IconName::PanelBottom) .tooltip(t!("settings_split_pane_down").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -4455,7 +4522,6 @@ impl Ashell { .child( pointer_button("split-vertical") .ghost() - .small() .icon(IconName::PanelRight) .tooltip(t!("settings_split_pane_right").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -4468,7 +4534,6 @@ impl Ashell { .child( pointer_button("tabbar-settings") .ghost() - .small() .icon(IconName::Settings) .tooltip(t!("settings_open_settings").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -4566,7 +4631,7 @@ impl Ashell { .child( pointer_button("close-command-history") .ghost() - .xsmall() + .small() .icon(IconName::Close) .tooltip(t!("close_command_history").to_string()) .on_click(cx.listener(|this, _, _, cx| { @@ -4594,7 +4659,6 @@ impl Ashell { .justify_center() .child( pointer_checkbox("command-history-select-all") - .small() .checked(all_history_selected) .disabled(!has_visible_history) .tab_stop(false) @@ -4612,7 +4676,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.75)) + .text_size(ui_rems(0.75)) .text_color(theme.muted_foreground) .child(format!("{selected_history}/{total_history}")), ) @@ -4620,7 +4684,6 @@ impl Ashell { .child( pointer_button("delete-selected-command-history") .danger() - .small() .icon(IconName::Delete) .label(t!("delete_selected_connections").to_string()) .tooltip(t!("delete_selected_commands").to_string()) @@ -4651,7 +4714,7 @@ impl Ashell { .flex() .items_center() .justify_center() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(theme.muted_foreground) .child(t!("command_history_empty")) .into_any_element() @@ -4662,7 +4725,7 @@ impl Ashell { .flex() .items_center() .justify_center() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(theme.muted_foreground) .child(t!("no_matching_commands")) .into_any_element() @@ -4744,7 +4807,6 @@ impl Ashell { pointer_checkbox(ElementId::Name( selection_id.into(), )) - .small() .checked(is_selected) .tab_stop(false) .on_click(cx.listener({ @@ -4766,7 +4828,7 @@ impl Ashell { .flex_1() .min_w(px(0.)) .truncate() - .text_size(rems(0.833)) + .text_size(ui_rems(0.833)) .text_color(row_theme.foreground) .child(command), ) @@ -4790,7 +4852,7 @@ impl Ashell { execute_id.into(), )) .ghost() - .xsmall() + .small() .icon(IconName::Play) .tooltip( t!("execute_command") @@ -4952,6 +5014,7 @@ impl Ashell { terminal::element::TerminalElementConfig { view: cx.entity(), focus_handle, + pane_focused: is_focused, snapshot, marked_text, font_family, @@ -4991,7 +5054,7 @@ impl Ashell { .cursor_pointer() .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(cx.theme().danger) .child( t!("session_disconnected", "reason" = reason) @@ -5000,7 +5063,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.85)) + .text_size(ui_rems(0.85)) .text_color(cx.theme().muted_foreground) .child(format!("— {}", t!("press_enter_to_reconnect"))), ) @@ -5252,28 +5315,38 @@ impl Render for Ashell { let view = cx.entity(); v_flex() .size_full() + .min_w(px(0.)) + .items_stretch() .child( - div().flex_1().min_h(px(0.)).child( - v_resizable("ashell-body") - .lock(self.config.lock_layout()) - .with_state(&self.body_panels) - .on_resize(move |_, _, cx| { - view.update(cx, |this, _| { - this.is_layout_reset = false; - }); - }) - .child(resizable_panel().child(self.render_terminal_panel(window, cx))) - .child( - resizable_panel() - .size(sftp_size) - .size_range(if self.sftp_panel_minimized { - px(minimized_height)..px(minimized_height) - } else { - px(min_panel_height)..px(1200.) - }) - .child(self.render_sftp_panel(window, cx)), - ), - ), + div() + .w_full() + .min_w(px(0.)) + .flex_1() + .min_h(px(0.)) + .overflow_hidden() + .child( + v_resizable("ashell-body") + .lock(self.config.lock_layout()) + .with_state(&self.body_panels) + .on_resize(move |_, _, cx| { + view.update(cx, |this, _| { + this.is_layout_reset = false; + }); + }) + .child( + resizable_panel().child(self.render_terminal_panel(window, cx)), + ) + .child( + resizable_panel() + .size(sftp_size) + .size_range(if self.sftp_panel_minimized { + px(minimized_height)..px(minimized_height) + } else { + px(min_panel_height)..px(1200.) + }) + .child(self.render_sftp_panel(window, cx)), + ), + ), ) .when(is_monitor_bottom, |this| { this.child(self.render_monitoring_panel( @@ -5286,10 +5359,15 @@ impl Render for Ashell { } else { v_flex() .size_full() + .min_w(px(0.)) + .items_stretch() .child( div() + .w_full() + .min_w(px(0.)) .flex_1() .min_h(px(0.)) + .overflow_hidden() .child(self.render_terminal_panel(window, cx)), ) .when(is_monitor_bottom, |this| { @@ -5331,9 +5409,10 @@ impl Render for Ashell { .flex_none() .child(self.sidebar(cx)); - let main_area = resizable_panel().child( + let main_area = resizable_panel().min_w(px(0.)).child( v_flex() .size_full() + .min_w(px(0.)) .relative() .overflow_hidden() .when( @@ -5467,6 +5546,35 @@ impl Render for Ashell { .flex_1() .min_w(px(0.)) .h_full() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _, _| { + this.should_move_window = true; + }), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, _| { + this.should_move_window = false; + }), + ) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|this, _, _, _| { + this.should_move_window = false; + }), + ) + .on_mouse_down_out(cx.listener(|this, _, _, _| { + this.should_move_window = false; + })) + .on_mouse_move(cx.listener(|this, _, window, _| { + if this.should_move_window { + // Preserve clicks and enter the native move loop only + // after dragging starts within the integrated top bar. + this.should_move_window = false; + crate::app::window_drag::start_window_drag(window); + } + })) .on_double_click(|_, window, _| { #[cfg(target_os = "macos")] window.titlebar_double_click(); @@ -5478,7 +5586,13 @@ impl Render for Ashell { ) }) .child( - div().flex_1().min_h_0().child(workspace), + div() + .w_full() + .min_w(px(0.)) + .flex_1() + .min_h_0() + .overflow_hidden() + .child(workspace), ) .children(Root::render_dialog_layer(window, cx)) .children(Root::render_sheet_layer(window, cx)) @@ -5633,7 +5747,7 @@ impl Render for Ashell { v_flex().gap_2().children( progress.lines.iter().cloned().map(|line| { div() - .text_size(rems(1.0)) + .text_size(ui_rems(1.0)) .text_color(if progress.failed { cx.theme().danger } else { diff --git a/src/app/window_drag.rs b/src/app/window_drag.rs new file mode 100644 index 0000000..39cf722 --- /dev/null +++ b/src/app/window_drag.rs @@ -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(); + } +} diff --git a/src/backend/local.rs b/src/backend/local.rs index d5732e8..e742c63 100644 --- a/src/backend/local.rs +++ b/src/backend/local.rs @@ -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 anyhow::Result { + #[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 { + path.is_file().then_some(path) +} + +#[cfg(windows)] +fn env_path(name: &str) -> Option { + std::env::var_os(name).map(PathBuf::from) +} + +#[cfg(windows)] +fn find_on_path(name: &str, predicate: impl Fn(&Path) -> bool) -> Option { + 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 { + 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 { + 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::>(); + 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 { + 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 { + 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 { 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); diff --git a/src/backend/ssh.rs b/src/backend/ssh.rs index 3d04c80..4665ba3 100644 --- a/src/backend/ssh.rs +++ b/src/backend/ssh.rs @@ -487,12 +487,7 @@ async fn connect_and_authenticate( session: &Session, events: &GuardedBackendEventSender, ) -> Result> { - 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 </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 diff --git a/src/session/config.rs b/src/session/config.rs index 5f2ea1c..1470706 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -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 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); diff --git a/src/session/mod.rs b/src/session/mod.rs index 3e566ea..22857a1 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -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 Option { + 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 { 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 { + 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, ) -> Option { - 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) { 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.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, + ) { + 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) { + pub(crate) fn connect_saved_session( + &mut self, + session_id: String, + window: &mut Window, + cx: &mut Context, + ) { 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) { + pub(crate) fn open_ssh_session( + &mut self, + session: Session, + window: &mut Window, + cx: &mut Context, + ) { 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()) + ); + } +} diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index 3dbbecc..04536ad 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -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>> { - 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; diff --git a/src/system/mod.rs b/src/system/mod.rs index 4fe3248..e43f4b3 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -192,7 +192,7 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result { .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 { .get("CPU_PERCENT") .ok_or_else(|| anyhow!("missing CPU_PERCENT"))? .parse::() + .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 { let mut fields = record.splitn(5, '\t'); let pid = fields.next()?.parse::().ok()?; let user = fields.next()?.to_string(); - let cpu_percent = fields.next()?.parse::().ok()?.max(0.0); + let cpu_percent = fields + .next()? + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|value| value.max(0.0))?; let memory_bytes = fields.next()?.parse::().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 { let mut fields = line.split_whitespace(); let pid = fields.next()?.parse::().ok()?; let user = fields.next()?.to_string(); - let cpu_percent = fields.next()?.parse::().ok()?.max(0.0); + let cpu_percent = fields + .next()? + .parse::() + .ok() + .filter(|value| value.is_finite()) + .map(|value| value.max(0.0))?; let memory_bytes = fields.next()?.parse::().ok()?.saturating_mul(1024); let command = fields.collect::>().join(" "); if command.is_empty() { @@ -327,7 +339,7 @@ fn parse_u64(kv: &BTreeMap, 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( diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 8228a8e..b777ac3 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -146,6 +146,7 @@ struct CursorLayout { pub struct TerminalElement { view: Entity, focus_handle: FocusHandle, + pane_focused: bool, snapshot: RenderSnapshot, marked_text: Option, font_family: SharedString, @@ -159,6 +160,7 @@ pub struct TerminalElement { pub(crate) struct TerminalElementConfig { pub(crate) view: Entity, pub(crate) focus_handle: FocusHandle, + pub(crate) pane_focused: bool, pub(crate) snapshot: RenderSnapshot, pub(crate) marked_text: Option, 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 { + fn cursor_layout(&self, window: &Window, cx: &App) -> Option { + 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, _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)); + } +} diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs index 2ad099c..87b8877 100644 --- a/src/terminal/highlight.rs +++ b/src/terminal/highlight.rs @@ -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, + 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 = 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 = 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 { } 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 { @@ -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::>(); + 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))); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 1b68a44..d00e7f5 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -778,7 +778,7 @@ pub enum BackendTx { Local(Sender), Ssh(tokio::sync::mpsc::UnboundedSender), Serial(tokio::sync::mpsc::UnboundedSender), - /// 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>, 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 {