diff --git a/CHANGELOG.md b/CHANGELOG.md index 62f8fc4..15dc62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,15 @@ 其他平台默认使用 `Ctrl+Q`。 - 在设置页快捷键面板增加“退出应用”配置项,支持修改或取消绑定。 - 新增窗口位置、尺寸及最大化或全屏状态记忆,并在显示器布局变化后自动校正窗口位置。 +- 新增系统级应用菜单,提供关于、文件、编辑、视图、窗口和帮助等常用操作。 - 新增“记忆标签页”设置,默认关闭。启用后可恢复标签组、分屏布局、活动标签、 本地工作目录和终端编码。 -- 本地终端和 SSH 标签页新增独立编码切换,支持 UTF-8、GB18030、GBK、Big5、 - Shift_JIS、EUC-KR 和 Windows-1252。 +- 新增终端编码切换,支持 UTF-8、GB18030、GBK、Big5、Shift_JIS、EUC-KR 和 + Windows-1252;SSH 标签页可独立设置,本地终端通过设置面板统一管理。 - 远程文件编辑器新增编码切换,额外支持 UTF-16 LE、UTF-16 BE、BOM 检测与保留。 - 新增 CSV 连接导入和导出功能。 +- 连接管理新增分组功能,支持新建、重命名、删除、折叠和批量移动连接; + 分组标题提供三态复选框,可按当前筛选结果批量选择或取消组内连接,删除分组时连接自动移至“未分组”。 - 连接列表新增筛选、批量选择、已选数量统计和批量删除功能。 - 新增全局 SSH 历史命令功能,支持筛选、选择、批量删除、复制和执行。 - 新增通用终端通知,支持 OSC 9、OSC 99、OSC 777 和 BEL,并在 macOS Dock 或 @@ -37,17 +40,26 @@ ### 变更 - 将英文连接管理标题由 `CONNECTION MANAGEMENT` 简化为 `CONNECTIONS`。 -- 侧边栏折叠后,连接管理改为类似历史命令的弹出列表,保留筛选、勾选、分组、 - 批量删除和独立连接按钮。 +- 默认使用融合标题栏,并重新整理标签栏样式与操作区;折叠入口位于标签栏左侧, + 搜索和设置入口位于右侧。 +- 工作区交互统一为下划线活动页签、平面连接列表和低干扰图标工具栏;标签过多时提供溢出菜单, + 空连接列表可直接新建连接。 +- 标签列表选择标签时,标签栏会以平滑动画定位到完整可见位置,同时保留手动滚动能力。 +- SSH 监控区增加当前会话身份条,显示会话名称、连接地址和连接状态;断开时保留状态上下文, + 但禁用远程资源详情入口。 +- 侧边栏折叠后完全隐藏,不再保留折叠态连接图标或连接列表入口。 - 重新整理连接管理操作区,集中展示导入、导出和新建连接操作。 - 连接列表改为名称靠左、连接地址靠右的整行布局,并保持列表项宽度一致。 - 点击连接行只切换勾选状态,只有点击连接图标才发起连接。 - 编辑连接保存后不再自动打开该连接。 - 新建和编辑页面标题调整为“新建连接”和“编辑连接”。 +- 新建和编辑 SSH、SSH Config 及串口连接时,分组字段改为下拉选择, + 只能选择“未分组”或已有分组,并自动回显原分组。 - SSH 表单改为 IP 与端口同行、账号与密码同行,并增加默认终端编码设置。 - 右上角加号改为紧凑下拉菜单,仅保留“本地终端”和“新建连接”。 -- CSV 导出格式精简为 `name,host,port,username,password`,不再导出 ID、认证类型、 +- CSV 导出格式精简为 `name,group,host,port,username,password`,不再导出 ID、认证类型、 私钥路径、私钥内容和私钥口令。 +- CSV 导入和导出支持可选连接分组,连接唯一标识仍使用 `host + port + username`。 - CSV 导入支持常见字段别名、UTF-8 BOM、带引号内容和多行密码,端口为空时默认使用 `22`。 - CSV 导入使用 `host + port + username` 识别同一连接,匹配时更新原配置并保留本地专用字段。 - 同一 CSV 中的重复连接以最后一条为准;本地存在多个相同标识时跳过更新并计入导入结果。 @@ -60,16 +72,20 @@ - 历史命令跨 SSH 标签页可用,当前连接的命令优先显示;超长命令保持单行并使用省略号。 - 点击历史命令行只切换勾选状态,执行和复制分别使用独立图标;执行后自动关闭弹出窗口。 - 相同历史命令自动去重并保留最新记录,每个 SSH 连接最多保存 200 条。 +- SSH 终端编码入口移至历史按钮右侧;本地终端编码改由设置面板统一管理。 - 远程文件面板只在当前标签为 SSH 时显示,并随活动 SSH 标签切换对应的 SFTP 会话。 - SSH 当前目录变化时自动同步远程文件路径。 - 远程文件面板改为左侧目录树、右侧当前目录内容的双栏布局,分隔宽度可拖动并持久化。 - 面板头部整合传输记录、历史命令和更多操作,移除原底部传输栏。 +- 精简远程文件面板头部,移除面板名称和左侧图标,并保留带文字的常用操作。 - 同步路径、刷新、添加和更多操作按钮增加名称并调整图标。 - 文件列表复选框固定在左侧,列宽支持独立拖动。 +- 文件列表支持点击高亮与 `Shift` 连续范围多选,范围内项目同步勾选并可直接批量操作。 - 文件列表仅在内容确实溢出时显示滚动条,并支持触摸板纵向和横向滚动。 - 文件右键菜单和其他下拉菜单根据文字长度自适应宽度。 - 远程文件编辑改为应用内编辑,支持按扩展名进行语法高亮、2 MB 文件限制和原权限保留。 - CPU、内存和网络模块仅在已连接 SSH 标签页中提供远程详情入口。 +- 监控显示位置设置统一应用于本地和 SSH 标签页;SSH 选择底部时,远程监控显示在文件面板下方。 - 进程表头与 CPU 或内存数据列保持对齐,复制操作位于终止操作之前。 - 统一按钮、复选框、删除、连接、执行和复制控件的鼠标指针反馈。 - 统一连接列表和历史命令列表的复选框对齐、行间距、操作区间距及选择数量显示。 @@ -77,6 +93,7 @@ ### 修复 +- 修复点击“新建分组”时程序崩溃的问题。 - 修复本地终端位于主目录时状态圆点越出标签栏的问题。 - 修复本地目录变化后标签仍显示旧路径或错误项目路径的问题。 - 修复管道、重定向等复合命令只记录最后一段内容的问题。 @@ -84,6 +101,8 @@ - 修复 Vim、`crontab -e` 等全屏编辑状态下的内容被错误记录为历史命令的问题。 - 修复历史命令重复、无故丢失或配置并发保存时被旧数据覆盖的问题。 - 修复连接列表和历史命令列表的滚动条遮挡右侧操作按钮的问题。 +- 修复 SSH 终端滚动条遮挡最右侧文字的问题。 +- 修复展开系统进程详情后列表行宽不一致的问题。 - 修复远程进程详情重复显示命令内容的问题。 - 修复空目录展开后目录图标缩进漂移、无法重新折叠的问题。 - 修复异步目录响应错误覆盖当前远程路径的问题。 @@ -97,6 +116,14 @@ - 修复系统进程和网络端口筛选框未随显示语言更新中英文占位文案的问题。 - 修复多个交互元素缺少指针光标及下拉菜单宽度过大的问题。 - 修复 macOS 构建中 Windows 专用窗口句柄参数产生未使用变量警告的问题。 +- 修复窗口尺寸变化以及本地终端最大化后恢复时,终端网格重复重排并产生空行的问题。 +- 修复窗口布局切换期间终端接收单列临时尺寸并导致界面卡死的问题。 +- 修复 SSH 连接后工作区横向收缩,命令窗口内容只能显示在狭窄区域的问题。 +- 修复本地终端链接识别错误保留末尾不成对括号的问题,同时保留链接内合法的成对括号。 +- 修复终端关键词高亮规则重叠时同一单词出现多种颜色的问题。 +- 修复终端关键词误匹配更长单词子串的问题,仅高亮完整单词或短语。 +- 修复标签列表菜单无法拖动调整标签分组顺序的问题,并同步更新标签栏与已保存的标签状态。 +- 修复标签列表选择标签时标签栏定位不准、末尾标签无法完整显示以及手动滚动被回弹的问题。 ### 兼容性与稳定性 diff --git a/locales/en.yml b/locales/en.yml index c8f507f..668aea7 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1,4 +1,3 @@ -remote_files: "REMOTE FILES" open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files." sftp_connecting: "sftp connecting..." refresh: "Refresh" @@ -89,13 +88,13 @@ use_light_mode: "Use Light Mode" use_dark_mode: "Use Dark Mode" english: "English" chinese: "简体中文" -idle_no_session: "idle / No session" cpu: "CPU" mem: "MEM" swap: "SWAP" net: "NET" disk: "DISK" live: "Live" +system_info: "SYSTEM" memory: "Memory" cpu_percent: "CPU" memory_usage_short: "Memory" @@ -153,7 +152,6 @@ no_matching_commands: "No matching commands" copy_command: "Copy command" execute_command: "Execute command" close_command_history: "Close command history" -close_connections: "Close connections" open_local_or_ssh: "Open a local terminal or pick an SSH session." open_session: "Open Session" settings: "Settings" @@ -173,7 +171,7 @@ cursor_style_default: "Default" cursor_style_blink: "Blink" cursor_style_beam: "Line" cursor_style_beam_blink: "Line Blink" -monitoring_position: "Local Monitoring Position" +monitoring_position: "Monitoring Position" position_bottom: "Bottom" position_sidebar: "Sidebar" position_hidden: "Hidden" @@ -231,6 +229,8 @@ settings_paste: "Paste Clipboard" edit_file: "Edit File" edit_file_tooltip: "Edit directly in ashell and save changes to the remote server." terminal_encoding: "Terminal Encoding" +local_terminal_encoding: "Local Terminal Encoding" +local_terminal_encoding_changed: "Local terminal encoding changed to %{encoding}." file_encoding: "File Encoding" terminal_encoding_changed: "Terminal encoding changed to %{encoding}; new input and output will use it." file_encoding_changed: "File encoding changed to %{encoding}." @@ -249,6 +249,31 @@ sync_cwd: "Sync Path" scroll: "Scroll ↓" clone: "Clone" about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project." +about_description: "A GPUI Component based SSH and local terminal client" +version: "Version" +close: "Close" +menu_about_ashell: "About Ashell" +menu_services: "Services" +menu_hide_ashell: "Hide Ashell" +menu_hide_others: "Hide Others" +menu_show_all: "Show All" +menu_quit_ashell: "Quit Ashell" +menu_file: "File" +menu_edit: "Edit" +menu_view: "View" +menu_help: "Help" +menu_close_window: "Close Window" +menu_undo: "Undo" +menu_redo: "Redo" +menu_cut: "Cut" +menu_copy: "Copy" +menu_paste: "Paste" +menu_select_all: "Select All" +menu_toggle_sidebar: "Toggle Sidebar" +menu_toggle_full_screen: "Enter Full Screen" +menu_minimize: "Minimize" +menu_zoom: "Zoom" +menu_project_website: "Ashell Project Website" settings_general: "General" settings_key_bindings: "Key Bindings" settings_help: "Help" @@ -266,13 +291,12 @@ transfers_limit: " (Max 100 records)" press_new_key: "Press new key..." none: "None" -settings_open_settings: "Open Settings" +settings_open_settings: "Settings" settings_open_session: "Open Session" +settings_tab_list: "Tab List" settings_open_transfers: "Open Transfer History" panel_minimize: "Minimize Panel" panel_expand: "Expand Panel" -panel_minimize_short: "Minimize" -panel_expand_short: "Expand" settings_new_connection: "New Connection" settings_open_search: "Search" settings_quit_application: "Quit Application" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 20e719b..3ffc4af 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -1,4 +1,3 @@ -remote_files: "远程文件" open_ssh_tab_sftp: "打开 SSH 标签页以浏览 SFTP 文件。" sftp_connecting: "正在连接 SFTP..." refresh: "刷新" @@ -89,13 +88,13 @@ use_light_mode: "使用浅色模式" use_dark_mode: "使用深色模式" english: "English" chinese: "简体中文" -idle_no_session: "空闲 / 未连接会话" cpu: "CPU" mem: "内存" swap: "虚拟内存" net: "网络" disk: "磁盘" live: "实时" +system_info: "系统信息" memory: "内存" cpu_percent: "CPU" memory_usage_short: "内存" @@ -146,14 +145,13 @@ connections_deleted: "已删除 %{count} 个连接" delete_selected_commands: "删除选中命令" commands_deleted: "已删除 %{count} 条命令" command_history: "历史命令" -command_history_short: "历史命令" +command_history_short: "历史" command_history_empty: "暂无历史命令" filter_command_history: "筛选历史命令" no_matching_commands: "没有匹配的命令" copy_command: "复制命令" execute_command: "执行命令" close_command_history: "关闭历史命令" -close_connections: "关闭连接列表" open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。" open_session: "打开会话" settings: "设置" @@ -173,7 +171,7 @@ cursor_style_default: "默认" cursor_style_blink: "闪烁" cursor_style_beam: "细线" cursor_style_beam_blink: "细线闪烁" -monitoring_position: "本机监控显示位置" +monitoring_position: "监控显示位置" position_bottom: "底部" position_sidebar: "侧边栏" position_hidden: "不显示" @@ -231,6 +229,8 @@ settings_paste: "粘贴剪贴板" edit_file: "编辑文件" edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服务器。" terminal_encoding: "终端编码" +local_terminal_encoding: "本地终端编码" +local_terminal_encoding_changed: "本地终端编码已切换为 %{encoding}。" file_encoding: "文件编码" terminal_encoding_changed: "终端编码已切换为 %{encoding},新的输入和输出将使用此编码。" file_encoding_changed: "文件编码已切换为 %{encoding}。" @@ -249,6 +249,31 @@ sync_cwd: "同步路径" scroll: "滑动查看 ↓" clone: "克隆" about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。" +about_description: "基于 GPUI Component 的 SSH 与本地终端客户端" +version: "版本" +close: "关闭" +menu_about_ashell: "关于 Ashell" +menu_services: "服务" +menu_hide_ashell: "隐藏 Ashell" +menu_hide_others: "隐藏其它应用" +menu_show_all: "全部显示" +menu_quit_ashell: "退出 Ashell" +menu_file: "文件" +menu_edit: "编辑" +menu_view: "视图" +menu_help: "帮助" +menu_close_window: "关闭窗口" +menu_undo: "撤销" +menu_redo: "重做" +menu_cut: "剪切" +menu_copy: "复制" +menu_paste: "粘贴" +menu_select_all: "全选" +menu_toggle_sidebar: "切换侧边栏" +menu_toggle_full_screen: "进入全屏" +menu_minimize: "最小化" +menu_zoom: "缩放" +menu_project_website: "Ashell 项目主页" settings_general: "常规" settings_key_bindings: "快捷键" settings_help: "帮助" @@ -266,13 +291,12 @@ transfers_limit: " (最多保留 100 条)" press_new_key: "请按下新的快捷键..." none: "无" -settings_open_settings: "打开设置" +settings_open_settings: "设置" settings_open_session: "打开会话" +settings_tab_list: "标签列表" settings_open_transfers: "打开传输历史" panel_minimize: "折叠面板" panel_expand: "展开面板" -panel_minimize_short: "折叠" -panel_expand_short: "展开" settings_new_connection: "新建连接" settings_open_search: "搜索" settings_quit_application: "退出应用" diff --git a/scripts/package-macos-app.sh b/scripts/package-macos-app.sh index 8b2d2c8..686b076 100755 --- a/scripts/package-macos-app.sh +++ b/scripts/package-macos-app.sh @@ -9,6 +9,13 @@ CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" SIGN_IDENTITY="${SIGN_IDENTITY:--}" +APP_VERSION="${APP_VERSION:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)}" +APP_BUILD_VERSION="${APP_BUILD_VERSION:-$APP_VERSION}" + +if [[ -z "$APP_VERSION" ]]; then + echo "error: unable to read the package version from Cargo.toml" >&2 + exit 1 +fi cd "$ROOT_DIR" cargo build --release @@ -40,9 +47,9 @@ cat > "$CONTENTS_DIR/Info.plist" <CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + $APP_VERSION CFBundleVersion - 1 + $APP_BUILD_VERSION LSMinimumSystemVersion 12.0 NSHighResolutionCapable diff --git a/src/app/constants.rs b/src/app/constants.rs index 553ba9d..519a6a8 100644 --- a/src/app/constants.rs +++ b/src/app/constants.rs @@ -1,7 +1,6 @@ pub(crate) const DEFAULT_COLS: u16 = 100; pub(crate) const DEFAULT_ROWS: u16 = 30; pub(crate) const SIDEBAR_WIDTH: f32 = 306.0; -pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0; pub(crate) const TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0; #[allow(dead_code)] diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 114d23e..b8c7a9e 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1750,7 +1750,6 @@ impl Ashell { if self.active_dialog.is_some() { return; } - self.show_collapsed_connections = false; let editing_group = group.clone(); let is_editing = editing_group.is_some(); let existing_groups = self.config.connection_groups(); @@ -3229,6 +3228,99 @@ impl Ashell { }); } + pub(crate) fn show_about_dialog(&mut self, window: &mut Window, cx: &mut Context) { + if self.active_dialog.is_some() { + return; + } + + self.active_dialog = Some(crate::app::DialogKind::About); + let view = cx.entity(); + let version = env!("CARGO_PKG_VERSION"); + + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + dialog + .title(t!("menu_about_ashell").to_string()) + .w(px(440.)) + .keyboard(false) + .on_close({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(crate::app::DialogKind::About) { + this.active_dialog = None; + } + cx.notify(); + }); + } + }) + .content(move |content, _window, cx| { + content.child( + v_flex() + .w_full() + .items_center() + .gap_3() + .py_4() + .child( + div() + .text_size(rems(1.5)) + .font_weight(FontWeight::BOLD) + .child("Ashell"), + ) + .child(div().text_size(rems(0.9)).child(format!( + "{} {}", + t!("version"), + version + ))) + .child( + div() + .text_size(rems(0.9)) + .text_color(cx.theme().muted_foreground) + .text_center() + .child(t!("about_description")), + ) + .child( + div() + .text_size(rems(0.9)) + .text_color(cx.theme().muted_foreground) + .text_center() + .child(t!("about_feedback_hint")), + ) + .child( + pointer_button("about-project-link") + .label("https://github.com/rust-kotlin/ashell") + .ghost() + .on_click(|_, _, _| { + if let Err(error) = + open::that("https://github.com/rust-kotlin/ashell") + { + tracing::warn!( + "failed to open Ashell project website: {error}" + ); + } + }), + ), + ) + }) + .footer({ + let view = view.clone(); + h_flex().w_full().justify_end().child( + pointer_button("about-close") + .primary() + .label(t!("close").to_string()) + .on_click(move |_, window, cx| { + view.update(cx, |this, cx| { + if this.active_dialog == Some(crate::app::DialogKind::About) { + this.active_dialog = None; + } + cx.notify(); + }); + window.close_dialog(cx); + }), + ) + }) + }); + } + pub(crate) fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() { return; @@ -3259,6 +3351,7 @@ impl Ashell { cx, &this.config, ); + crate::app::system_menu::set_app_menus(cx); cx.notify(); }); } @@ -3702,6 +3795,39 @@ impl Ashell { }) ) ) + .item( + SettingItem::new( + t!("local_terminal_encoding").to_string(), + SettingField::render({ + let view = view_clone_for_general.clone(); + move |_, _window, cx| { + let current = view.read(cx).config.local_terminal_encoding(); + pointer_button("local-terminal-encoding-dropdown") + .small() + .icon(IconName::Globe) + .label(current.label()) + .dropdown_menu_with_anchor(Anchor::BottomRight, { + let view = view.clone(); + move |mut menu, window, cx| { + let current = view.read(cx).config.local_terminal_encoding(); + menu = menu.min_w(0.); + for encoding in TERMINAL_ENCODINGS.iter().copied() { + menu = menu.item( + PopupMenuItem::new(encoding.label()) + .checked(encoding == current) + .on_click(window.listener_for(&view, move |this, _, _, cx| { + this.change_local_terminal_encoding(encoding, cx); + })), + ); + } + menu + } + }) + .into_any_element() + } + }) + ) + ) .item( SettingItem::new( t!("cursor_style").to_string(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 3fddabe..6710eb7 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -6,6 +6,7 @@ pub mod keybinding_recorder; pub mod resizable; pub mod search; pub mod startup; +pub mod system_menu; pub mod theme; pub mod ui; @@ -213,6 +214,7 @@ impl ScrollbarHandle for TerminalScrollbarHandle { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DialogKind { + About, Settings, SessionSelector, ConnectionExport, @@ -312,6 +314,8 @@ pub(crate) struct Ashell { pub(crate) process_scroll_handle: gpui::ScrollHandle, pub(crate) port_scroll_handle: UniformListScrollHandle, pub(crate) tabs_scroll_handle: gpui::ScrollHandle, + pub(crate) tabs_viewport_bounds: Option>, + pub(crate) tab_scroll_animation_id: u64, pub(crate) selector_scroll_handle: gpui::ScrollHandle, pub(crate) saved_scroll_handle: gpui::ScrollHandle, pub(crate) command_history_scroll_handle: gpui::ScrollHandle, @@ -331,7 +335,6 @@ pub(crate) struct Ashell { pub(crate) transfers: Vec, pub(crate) show_transfers_dialog: bool, pub(crate) show_command_history: bool, - pub(crate) show_collapsed_connections: bool, pub(crate) ssh_command_buffers: HashMap, pub(crate) ssh_command_starts: HashMap, pub(crate) system_status: Option, @@ -354,8 +357,6 @@ pub(crate) struct Ashell { pub(crate) terminal_marked_text: Option, pub(crate) sftp_panel_minimized: bool, pub(crate) sidebar_collapsed: bool, - pub(crate) collapsed_saved_scroll_handle: gpui::ScrollHandle, - pub(crate) collapsed_saved_sessions_overflowing: bool, pub(crate) window_active: bool, pub(crate) native_window_handle: Option, pub(crate) unread_terminal_notifications: HashSet, @@ -395,7 +396,8 @@ pub(crate) struct Ashell { pub(crate) events_tx: mpsc::Sender, pub(crate) last_window_size: Option>, pub(crate) last_sidebar_width: Option, - pub(crate) should_move_window: bool, + pub(crate) pending_local_terminal_resizes: HashMap, + pub(crate) local_terminal_resize_task: Option>, pub(crate) hovered_url: Option, pub(crate) cmd_ctrl_pressed: bool, pub(crate) _subscriptions: Vec, @@ -826,6 +828,8 @@ impl Ashell { process_scroll_handle: gpui::ScrollHandle::new(), port_scroll_handle: UniformListScrollHandle::new(), tabs_scroll_handle: gpui::ScrollHandle::new(), + tabs_viewport_bounds: None, + tab_scroll_animation_id: 0, selector_scroll_handle: gpui::ScrollHandle::new(), saved_scroll_handle: gpui::ScrollHandle::new(), command_history_scroll_handle: gpui::ScrollHandle::new(), @@ -858,7 +862,6 @@ impl Ashell { }, show_transfers_dialog: false, show_command_history: false, - show_collapsed_connections: false, ssh_command_buffers: HashMap::new(), ssh_command_starts: HashMap::new(), system_status: None, @@ -878,8 +881,6 @@ impl Ashell { drag_split_origin: None, sftp_panel_minimized: config.sftp_panel_minimized(), sidebar_collapsed: config.sidebar_collapsed(), - collapsed_saved_scroll_handle: gpui::ScrollHandle::new(), - collapsed_saved_sessions_overflowing: false, window_active: window.is_window_active(), native_window_handle: crate::desktop_notification::native_window_handle(window), unread_terminal_notifications: HashSet::new(), @@ -916,7 +917,8 @@ impl Ashell { events_tx, last_window_size: None, last_sidebar_width, - should_move_window: false, + pending_local_terminal_resizes: HashMap::new(), + local_terminal_resize_task: None, hovered_url: None, cmd_ctrl_pressed: false, _subscriptions, diff --git a/src/app/search.rs b/src/app/search.rs index 8088124..d9bbe87 100644 --- a/src/app/search.rs +++ b/src/app/search.rs @@ -326,7 +326,6 @@ impl Ashell { pointer_button("search-btn") .ghost() .small() - .rounded(px(999.)) .icon(IconName::Search) .tooltip(t!("search").to_string()) .on_click(cx.listener(|this, _, window, cx| { diff --git a/src/app/startup.rs b/src/app/startup.rs index 6f029a7..8e7f674 100644 --- a/src/app/startup.rs +++ b/src/app/startup.rs @@ -343,6 +343,7 @@ pub(crate) fn open_main_window(cx: &mut App) { window.set_window_title("ashell"); gpui_component::Theme::sync_system_appearance(Some(window), cx); let view = cx.new(|cx| Ashell::new(window, cx)); + crate::app::system_menu::set_app_menus(cx); tracing::info!("[ui] main application window opened"); let focus_handle = view.read(cx).focus_handle.clone(); diff --git a/src/app/system_menu.rs b/src/app/system_menu.rs new file mode 100644 index 0000000..17dfaa0 --- /dev/null +++ b/src/app/system_menu.rs @@ -0,0 +1,132 @@ +#[cfg(target_os = "macos")] +use gpui::KeyBinding; +use gpui::{App, Menu, MenuItem}; +use rust_i18n::t; + +gpui::actions!( + ashell_system_menu, + [ + AboutAshell, + CloseWindow, + HideAshell, + HideOtherApplications, + MinimizeWindow, + NewLocalTerminal, + OpenProjectWebsite, + ShowAllApplications, + ToggleFullScreen, + ZoomWindow + ] +); + +const PROJECT_URL: &str = "https://github.com/rust-kotlin/ashell"; + +pub(crate) fn init(cx: &mut App) { + #[cfg(target_os = "macos")] + { + cx.on_action(|_: &HideAshell, cx| cx.hide()); + cx.on_action(|_: &HideOtherApplications, cx| cx.hide_other_apps()); + cx.on_action(|_: &ShowAllApplications, cx| cx.unhide_other_apps()); + cx.bind_keys([ + KeyBinding::new("cmd-h", HideAshell, None), + KeyBinding::new("alt-cmd-h", HideOtherApplications, None), + KeyBinding::new("cmd-m", MinimizeWindow, None), + KeyBinding::new("ctrl-cmd-f", ToggleFullScreen, None), + ]); + } + + cx.on_action(|_: &OpenProjectWebsite, _| { + if let Err(error) = open::that(PROJECT_URL) { + tracing::warn!("failed to open Ashell project website: {error}"); + } + }); +} + +pub(crate) fn set_app_menus(cx: &mut App) { + let mut application_items = vec![ + MenuItem::action(t!("menu_about_ashell").to_string(), AboutAshell), + MenuItem::separator(), + MenuItem::action(t!("settings").to_string(), crate::OpenSettings), + ]; + + #[cfg(target_os = "macos")] + application_items.extend([ + MenuItem::separator(), + MenuItem::os_submenu( + t!("menu_services").to_string(), + gpui::SystemMenuType::Services, + ), + MenuItem::separator(), + MenuItem::action(t!("menu_hide_ashell").to_string(), HideAshell), + MenuItem::action(t!("menu_hide_others").to_string(), HideOtherApplications), + MenuItem::action(t!("menu_show_all").to_string(), ShowAllApplications), + ]); + + application_items.extend([ + MenuItem::separator(), + MenuItem::action(t!("menu_quit_ashell").to_string(), crate::QuitApplication), + ]); + + cx.set_menus([ + Menu::new("Ashell").items(application_items), + Menu::new(t!("menu_file").to_string()).items([ + MenuItem::action(t!("local_terminal").to_string(), NewLocalTerminal), + MenuItem::action(t!("new_connection").to_string(), crate::NewSsh), + MenuItem::separator(), + MenuItem::action(t!("menu_close_window").to_string(), CloseWindow), + ]), + Menu::new(t!("menu_edit").to_string()).items([ + MenuItem::os_action( + t!("menu_undo").to_string(), + gpui_component::input::Undo, + gpui::OsAction::Undo, + ), + MenuItem::os_action( + t!("menu_redo").to_string(), + gpui_component::input::Redo, + gpui::OsAction::Redo, + ), + MenuItem::separator(), + MenuItem::os_action( + t!("menu_cut").to_string(), + gpui_component::input::Cut, + gpui::OsAction::Cut, + ), + MenuItem::os_action( + t!("menu_copy").to_string(), + crate::Copy, + gpui::OsAction::Copy, + ), + MenuItem::os_action( + t!("menu_paste").to_string(), + crate::Paste, + gpui::OsAction::Paste, + ), + MenuItem::separator(), + MenuItem::os_action( + t!("menu_select_all").to_string(), + gpui_component::input::SelectAll, + gpui::OsAction::SelectAll, + ), + ]), + Menu::new(t!("menu_view").to_string()).items([ + MenuItem::action(t!("search").to_string(), crate::OpenSearch), + MenuItem::action(t!("menu_toggle_sidebar").to_string(), crate::ToggleSidebar), + MenuItem::action( + t!("settings_open_transfers").to_string(), + crate::OpenTransfers, + ), + MenuItem::separator(), + MenuItem::action(t!("menu_toggle_full_screen").to_string(), ToggleFullScreen), + ]), + // GPUI recognizes this exact title as the native macOS Window menu. + Menu::new("Window").items([ + MenuItem::action(t!("menu_minimize").to_string(), MinimizeWindow), + MenuItem::action(t!("menu_zoom").to_string(), ZoomWindow), + ]), + Menu::new(t!("menu_help").to_string()).items([MenuItem::action( + t!("menu_project_website").to_string(), + OpenProjectWebsite, + )]), + ]); +} diff --git a/src/app/theme.rs b/src/app/theme.rs index 00cb4a0..6ed2d54 100644 --- a/src/app/theme.rs +++ b/src/app/theme.rs @@ -127,6 +127,7 @@ impl Ashell { } rust_i18n::set_locale(&active_locale); gpui_component::set_locale(&active_locale); + crate::app::system_menu::set_app_menus(cx); self.connection_filter_input.update(cx, |input, cx| { input.set_placeholder(t!("filter_connections").to_string(), window, cx); }); diff --git a/src/app/ui.rs b/src/app/ui.rs index 23af635..10b4834 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -1,9 +1,13 @@ -use std::collections::HashSet; +use std::{ + collections::HashSet, + sync::Arc, + time::{Duration, Instant}, +}; use crate::app::resizable::{h_resizable, resizable_panel, v_resizable}; use gpui::{ - Anchor, AppContext as _, Context, ElementId, Focusable as _, FontWeight, Hsla, - InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, + 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, }; @@ -18,7 +22,6 @@ use gpui_component::{ progress::Progress, scroll::{ScrollableElement as _, Scrollbar, ScrollbarShow}, spinner::Spinner, - tab::{Tab, TabBar}, v_flex, }; use rust_i18n::t; @@ -27,9 +30,7 @@ use crate::{ Ashell, PaneLayout, app::{ ServerMonitorView, - constants::{ - COLLAPSED_SIDEBAR_WIDTH, SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT, TERMINAL_SCROLLBAR_GUTTER, - }, + constants::{SIDEBAR_WIDTH, TERMINAL_KEY_CONTEXT, TERMINAL_SCROLLBAR_GUTTER}, controls::{ PointerClipboard, PointerSelectionCheckbox, SelectionState, pointer_button, pointer_checkbox, @@ -97,6 +98,11 @@ struct ConnectionGroupSection { sessions: Vec, } +#[derive(Clone)] +struct TabGroupDrag { + group_id: String, +} + fn compact_menu_width(labels: &[&str]) -> Pixels { let display_units = labels .iter() @@ -110,18 +116,153 @@ fn compact_menu_width(labels: &[&str]) -> Pixels { px((display_units * 7.2 + 28.0).clamp(72.0, 240.0)) } +const TAB_SCROLL_ANIMATION_DURATION: Duration = Duration::from_millis(180); + impl Ashell { + fn tab_scroll_target_x(&self, index: usize) -> Option { + let scroll_handle = &self.tabs_scroll_handle; + let scroll_bounds = scroll_handle.bounds(); + let viewport = self + .tabs_viewport_bounds + .map(|bounds| bounds.intersect(&scroll_bounds)) + .unwrap_or(scroll_bounds); + let tab_bounds = scroll_handle.bounds_for_item(index)?; + + if viewport.size.width <= px(0.) { + return None; + } + + let offset = scroll_handle.offset(); + let visible_left = tab_bounds.left() + offset.x; + let visible_right = tab_bounds.right() + offset.x; + let target_x = if tab_bounds.size.width >= viewport.size.width { + viewport.left() - tab_bounds.left() + } else if visible_left < viewport.left() { + offset.x + viewport.left() - visible_left + } else if visible_right > viewport.right() { + offset.x + viewport.right() - visible_right + } else { + offset.x + }; + + let max_offset = scroll_handle.max_offset(); + Some(target_x.clamp(-max_offset.x, px(0.))) + } + + fn animate_tab_scroll( + &mut self, + start_x: Pixels, + target_x: Pixels, + started_at: Instant, + animation_id: u64, + 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 progress = (started_at.elapsed().as_secs_f32() + / TAB_SCROLL_ANIMATION_DURATION.as_secs_f32()) + .clamp(0., 1.); + let eased = 1. - (1. - progress).powi(3); + let current_x = if progress >= 1. { + target_x + } else { + px(start_x.as_f32() + (target_x.as_f32() - start_x.as_f32()) * eased) + }; + let offset = this.tabs_scroll_handle.offset(); + this.tabs_scroll_handle + .set_offset(point(current_x, offset.y)); + cx.notify(); + + if progress < 1. { + this.animate_tab_scroll(start_x, target_x, started_at, animation_id, window, cx); + } + }); + } + + 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; + cx.on_next_frame(window, move |this, window, cx| { + let Some(target_x) = this.tab_scroll_target_x(index) else { + return; + }; + let start_x = this.tabs_scroll_handle.offset().x; + if (target_x.as_f32() - start_x.as_f32()).abs() <= 0.5 { + return; + } + + this.animate_tab_scroll(start_x, target_x, Instant::now(), animation_id, window, cx); + }); + } + + fn tab_group_display_name(&self, group: &crate::app::TabGroup) -> String { + let pane_ids = group.pane_root.tab_ids(); + let configured_title = group.title.trim(); + let base_title = if configured_title.is_empty() { + pane_ids + .iter() + .find_map(|tab_id| { + self.tabs.iter().find(|tab| tab.id == *tab_id).map(|tab| { + let tab_title = tab.title.trim(); + if !tab_title.is_empty() { + return tab_title.to_string(); + } + + if let Some(session) = tab.session.as_ref() { + if !session.name.trim().is_empty() { + return session.name.trim().to_string(); + } + + if session.protocol == "serial" { + return format!("serial://{}", session.host); + } + + return format!("{}@{}:{}", session.user, session.host, session.port); + } + + t!("local_terminal").to_string() + }) + }) + .unwrap_or_else(|| t!("local_terminal").to_string()) + } else { + configured_title.to_string() + }; + + if pane_ids.len() > 1 { + format!("{} ({})", base_title, pane_ids.len()) + } else { + base_title + } + } + fn render_home_page(&self, cx: &mut Context) -> impl IntoElement { v_flex() .w_full() .h_full() .items_center() .justify_center() - .gap_4() + .gap_3() + .child( + h_flex() + .size(px(40.)) + .items_center() + .justify_center() + .rounded_md() + .bg(cx.theme().secondary) + .child( + Icon::new(IconName::SquareTerminal) + .with_size(Size::Large) + .text_color(cx.theme().primary), + ), + ) .child( div() - .text_size(rems(2.333)) - .font_weight(FontWeight::BOLD) + .text_size(rems(1.5)) + .font_weight(FontWeight::SEMIBOLD) .child("Ashell"), ) .child( @@ -248,6 +389,55 @@ impl Ashell { .child(self.render_command_history_popover_content(cx)) } + fn render_terminal_encoding_button( + &self, + id: &'static str, + cx: &mut Context, + ) -> impl IntoElement { + let active_terminal_encoding = self.active_tab.as_ref().and_then(|active_id| { + self.tabs + .iter() + .find(|tab| tab.id == *active_id && tab.kind == TabKind::Ssh) + .map(|tab| (tab.id.clone(), tab.text_encoding())) + }); + + h_flex().when_some(active_terminal_encoding, |this, (tab_id, encoding)| { + this.child( + pointer_button(id) + .ghost() + .small() + .icon(IconName::Globe) + .label(encoding.label()) + .tooltip(t!("terminal_encoding").to_string()) + .dropdown_menu_with_anchor(Anchor::BottomRight, { + let view = cx.entity(); + move |menu, window, _| { + TERMINAL_ENCODINGS.iter().copied().fold( + menu.min_w(0.), + |menu, candidate| { + let tab_id = tab_id.clone(); + menu.item( + PopupMenuItem::new(candidate.label()) + .checked(candidate == encoding) + .on_click(window.listener_for( + &view, + move |this, _, _, cx| { + this.set_terminal_encoding( + tab_id.clone(), + candidate, + cx, + ); + }, + )), + ) + }, + ) + } + }), + ) + }) + } + fn render_sftp_minimized_bar(&self, cx: &mut Context) -> impl IntoElement { h_flex() .flex_none() @@ -269,12 +459,12 @@ impl Ashell { Anchor::BottomRight, cx, )) + .child(self.render_terminal_encoding_button("sftp-terminal-encoding-minimized", cx)) .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); @@ -303,21 +493,7 @@ impl Ashell { .border_b_1() .border_color(cx.theme().border) .bg(cx.theme().tab_bar) - .child( - Icon::new(IconName::FolderOpen) - .with_size(Size::Small) - .text_color(cx.theme().primary), - ) - .child( - div() - .flex_1() - .min_w(px(0.)) - .truncate() - .text_size(rems(1.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(cx.theme().primary) - .child(t!("remote_files")), - ) + .child(div().flex_1().min_w(px(0.))) .when(!self.sftp_panel_minimized, |this| { this.child(self.render_transfers_button("sftp-transfers-header", cx)) .child(self.render_command_history_popover( @@ -326,6 +502,7 @@ impl Ashell { Anchor::TopRight, cx, )) + .child(self.render_terminal_encoding_button("sftp-terminal-encoding", cx)) }) .when(active_sftp.is_some(), |this| { this.child( @@ -383,7 +560,6 @@ impl Ashell { .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); @@ -1161,6 +1337,11 @@ impl Ashell { let selected_path = selected_path.clone(); let view = view.clone(); let theme = cx.theme().clone(); + let visible_entry_paths: Arc<[String]> = entries + .iter() + .map(|entry| entry.full_path.clone()) + .collect::>() + .into(); let horizontal_scroll_handle = file_columns_scroll_handle.clone(); uniform_list( @@ -1178,13 +1359,13 @@ impl Ashell { selected_entries.contains( &entry.full_path, ); - let is_selected = selected_path - .as_deref() - == Some( - entry - .full_path - .as_str(), - ); + let is_selected = is_checked + || selected_path.as_deref() + == Some( + entry + .full_path + .as_str(), + ); let name_color = if entry.is_dir { theme.primary } else { @@ -1226,14 +1407,21 @@ impl Ashell { let entry = entry .clone(); + let visible_entry_paths = + visible_entry_paths + .clone(); move |this, - _, + event: &MouseDownEvent, _, cx| { this.dismiss_sftp_context_menu(cx); this.select_sftp_entry( entry .clone(), + &visible_entry_paths, + event + .modifiers + .shift, cx, ); } @@ -1594,6 +1782,7 @@ impl Ashell { fn render_monitoring_panel( &mut self, viewport_width: Pixels, + interactive: bool, cx: &mut Context, ) -> impl IntoElement { let cpu_color = cx.theme().chart_1; @@ -1671,12 +1860,24 @@ impl Ashell { // --- CPU card --- let cpu_card = v_flex() + .id("bottom-cpu-module") .min_w(card_min_w) .flex_1() .h_full() .px_1() .py_1() .gap_0p5() + .when(interactive, |this| { + this.rounded_md() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().secondary)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.show_remote_processes_dialog(ServerMonitorView::Cpu, window, cx) + }), + ) + }) .child( h_flex() .w_full() @@ -1735,12 +1936,24 @@ impl Ashell { // --- MEM card: mem + swap --- let mem_card = v_flex() + .id("bottom-memory-module") .min_w(card_min_w) .flex_1() .h_full() .px_1() .py_1() .gap_0p5() + .when(interactive, |this| { + this.rounded_md() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().secondary)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.show_remote_processes_dialog(ServerMonitorView::Memory, window, cx) + }), + ) + }) .child( h_flex() .w_full() @@ -1805,12 +2018,24 @@ impl Ashell { let net_card = if show_net_card { Some( v_flex() + .id("bottom-network-module") .min_w(card_min_w) .flex_1() .h_full() .px_1() .py_1() .gap_0p5() + .when(interactive, |this| { + this.rounded_md() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().secondary)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + this.show_remote_ports_dialog(window, cx) + }), + ) + }) .child( h_flex() .w_full() @@ -2039,11 +2264,66 @@ impl Ashell { let disk_color = cx.theme().chart_5; let net_color = cx.theme().chart_4; let muted_fg = cx.theme().muted_foreground; + let active_is_ssh = matches!(self.active_kind(), Some(TabKind::Ssh)); + let (monitor_title, monitor_detail) = self + .active_tab + .as_ref() + .and_then(|active_id| self.tabs.iter().find(|tab| tab.id == *active_id)) + .and_then(|tab| tab.session.as_ref()) + .map(|session| (session.name.clone(), self.session_detail(session))) + .unwrap_or_else(|| (t!("system_info").to_string(), t!("live").to_string())); + let status_color = if interactive { + cx.theme().success + } else if active_is_ssh { + cx.theme().danger + } else { + muted_fg + }; v_flex() - .gap_4() + .gap_3() .w_full() .p_2() + .child( + h_flex() + .w_full() + .min_w(px(0.)) + .gap_2() + .pb_2() + .border_b_1() + .border_color(cx.theme().border) + .items_center() + .child(div().size(px(7.)).rounded_full().bg(status_color)) + .child( + v_flex() + .flex_1() + .min_w(px(0.)) + .gap_0p5() + .child( + div() + .min_w(px(0.)) + .truncate() + .text_size(rems(0.8)) + .font_weight(FontWeight::SEMIBOLD) + .child(monitor_title), + ) + .child( + div() + .min_w(px(0.)) + .truncate() + .text_size(rems(0.7)) + .text_color(muted_fg) + .child(monitor_detail), + ), + ) + .child( + div() + .flex_none() + .text_size(rems(0.7)) + .text_color(muted_fg) + .child(t!("system_info")), + ), + ) .child( v_flex() .id("sidebar-cpu-module") @@ -3024,19 +3304,20 @@ impl Ashell { .id(row_id) .w_full() .min_w(px(0.)) - .px_1() + .pl(px(3.)) + .pr_1() .py_1() - .rounded_md() - .border_1() + .rounded_sm() + .border_l_2() .border_color(if is_active { cx.theme().primary } else { - cx.theme().border + cx.theme().transparent }) .bg(if is_active { cx.theme().tab_active } else { - cx.theme().muted + cx.theme().transparent }) .hover(|this| this.bg(cx.theme().secondary)) .cursor_pointer() @@ -3209,7 +3490,8 @@ impl Ashell { .w_full() .min_w(px(0.)) .h(px(28.)) - .px_1() + .pl(px(5.)) + .pr_1() .items_center() .gap_1() .rounded_md() @@ -3338,7 +3620,7 @@ impl Ashell { ) .when(!collapsed, |this| { this.child( - v_flex().w_full().min_w(px(0.)).gap_2().children( + v_flex().w_full().min_w(px(0.)).gap_1().children( section.sessions.into_iter().map(|session| { self.render_connection_row(session, active_session_id, cx) }), @@ -3361,6 +3643,7 @@ impl Ashell { } else { t!("no_matching_connections").to_string() }; + let no_saved_connections = self.config.sessions().is_empty(); let has_group_sections = !group_sections.is_empty(); let visible_session_ids = group_sections .iter() @@ -3385,7 +3668,6 @@ impl Ashell { let saved_sessions_scroll_handle = self.saved_scroll_handle.clone(); let sidebar_view = cx.entity(); let active_session_id = self.active_session_id().map(ToOwned::to_owned); - let is_active_ssh = matches!(self.active_kind(), Some(TabKind::Ssh)); let is_active_ssh_connected = self .active_tab .as_ref() @@ -3393,90 +3675,18 @@ impl Ashell { .is_some_and(|tab| tab.kind == TabKind::Ssh && tab.connected); v_flex() - .gap_4() + .gap_3() .w_full() .h_full() .min_w(px(0.)) - .p_4() + .p_3() .border_r_1() .border_color(cx.theme().sidebar_border) .bg(cx.theme().sidebar) .overflow_hidden() - .child( - v_flex() - .gap_1() - .min_w(px(0.)) - .child( - h_flex() - .items_center() - .gap_2() - .child( - div() - .font_weight(FontWeight::BOLD) - .text_size(rems(1.667)) - .text_color(cx.theme().primary) - .child("Ashell"), - ) - .child(div().flex_1()) - .child( - pointer_button("sidebar-collapse-toggle") - .ghost() - .icon(IconName::PanelLeftClose) - .tooltip(t!("settings_toggle_sidebar").to_string()) - .on_click(cx.listener(|this, _, _, cx| { - this.sidebar_collapsed = true; - this.config.set_sidebar_collapsed(true); - this.save_preferences_background(); - cx.notify(); - })), - ) - .child( - pointer_button("sidebar-settings") - .ghost() - .icon(IconName::Settings) - .tooltip(t!("settings_open_settings").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_settings_dialog(window, cx) - })), - ), - ) - .child( - div() - .text_size(rems(0.917)) - .text_color(cx.theme().muted_foreground) - .truncate() - .child({ - if let Some(kind) = self.active_kind() { - match kind { - TabKind::Local => t!("local_terminal").to_string(), - TabKind::Ssh => { - if let Some((_, session)) = self.active_ssh_session() { - format!("ssh / {}", session.name) - } else { - "ssh".to_string() - } - } - TabKind::Serial => { - if let Some((_, session)) = self.active_ssh_session() { - format!("serial / {}", session.name) - } else { - "serial".to_string() - } - } - } - } else { - self.active_title() - } - }), - ), - ) - .when(is_active_ssh, |this| { + .when(self.config.monitoring_position() == "Sidebar", |this| { this.child(self.render_sidebar_monitoring_panel(is_active_ssh_connected, cx)) }) - .when( - !is_active_ssh && self.config.monitoring_position() == "Sidebar", - |this| this.child(self.render_sidebar_monitoring_panel(false, cx)), - ) .child( v_flex() .flex_1() @@ -3491,16 +3701,16 @@ impl Ashell { div() .flex_1() .min_w(px(0.)) - .text_size(rems(1.0)) + .text_size(rems(0.833)) .font_weight(FontWeight::SEMIBOLD) - .text_color(cx.theme().primary) + .text_color(cx.theme().foreground) .truncate() .child(t!("connection_management")), ) .child( pointer_button("import-connections") .ghost() - .small() + .xsmall() .icon(IconName::ArrowDown) .label(t!("import_connections").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -3510,7 +3720,7 @@ impl Ashell { .child( pointer_button("export-connections") .ghost() - .small() + .xsmall() .icon(IconName::ArrowUp) .label(t!("export_connections").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -3520,7 +3730,7 @@ impl Ashell { .child( pointer_button("open-ssh-panel") .primary() - .small() + .xsmall() .icon(IconName::Plus) .label(t!("new_connection_short").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -3644,18 +3854,40 @@ impl Ashell { .h_full() .when(!has_group_sections, |this| { this.child( - div() + v_flex() .absolute() .top_0() .bottom_0() .left_0() .right_0() - .flex() .items_center() .justify_center() - .text_size(rems(0.833)) - .text_color(cx.theme().muted_foreground) - .child(empty_connections_message.clone()), + .gap_2() + .child( + Icon::new(IconName::SquareTerminal) + .with_size(Size::Medium) + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_size(rems(0.833)) + .text_color(cx.theme().muted_foreground) + .child(empty_connections_message.clone()), + ) + .when(no_saved_connections, |this| { + this.child( + pointer_button("empty-new-connection") + .secondary() + .small() + .icon(IconName::Plus) + .label(t!("new_connection").to_string()) + .on_click(cx.listener( + |this, _, window, cx| { + this.open_new_ssh_dialog(window, cx) + }, + )), + ) + }), ) }) .child( @@ -3704,347 +3936,6 @@ impl Ashell { ) } - fn render_collapsed_connections_popover_content( - &self, - cx: &mut Context, - ) -> gpui::AnyElement { - let connection_filter = self - .connection_filter_input - .read(cx) - .value() - .trim() - .to_lowercase(); - let group_sections = self.connection_group_sections(&connection_filter); - let visible_session_ids = group_sections - .iter() - .flat_map(|section| section.sessions.iter()) - .map(|session| session.id.clone()) - .collect::>(); - let has_connections = !visible_session_ids.is_empty(); - let all_connections_selected = has_connections - && visible_session_ids - .iter() - .all(|id| self.selected_connection_ids.contains(id)); - let total_connections = self.config.sessions().len(); - let selected_connections = self - .config - .sessions() - .iter() - .filter(|session| self.selected_connection_ids.contains(&session.id)) - .count(); - let has_selected_connections = selected_connections > 0; - let has_group_sections = !group_sections.is_empty(); - let connection_groups = self.config.connection_groups(); - let active_session_id = self.active_session_id().map(ToOwned::to_owned); - let expanded_session_count = group_sections - .iter() - .filter(|section| { - !connection_filter.is_empty() - || !self.config.is_connection_group_collapsed(§ion.name) - }) - .map(|section| section.sessions.len()) - .sum::(); - let estimated_list_height = if has_group_sections { - (group_sections.len() as f32 * 36. + expanded_session_count as f32 * 40.) - .clamp(112., 420.) - } else { - 88. - }; - let empty_message = if self.config.sessions().is_empty() { - t!("no_connections").to_string() - } else { - t!("no_matching_connections").to_string() - }; - let collapsed_saved_sessions_overflowing = self.collapsed_saved_sessions_overflowing; - let collapsed_saved_scroll_handle = self.collapsed_saved_scroll_handle.clone(); - let sidebar_view = cx.entity(); - let theme = cx.theme().clone(); - - v_flex() - .w_full() - .p_3() - .gap_2() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation()) - .child( - h_flex() - .w_full() - .items_center() - .gap_1() - .child( - div() - .flex_1() - .min_w(px(0.)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(theme.foreground) - .child(t!("connection_management")), - ) - .child( - pointer_button("collapsed-import-connections") - .ghost() - .xsmall() - .icon(IconName::ArrowDown) - .tooltip(t!("import_connections").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_collapsed_connections = false; - this.import_connections(window, cx); - })), - ) - .child( - pointer_button("collapsed-export-connections") - .ghost() - .xsmall() - .icon(IconName::ArrowUp) - .tooltip(t!("export_connections").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_collapsed_connections = false; - this.export_connections(window, cx); - })), - ) - .child( - pointer_button("collapsed-new-connection") - .primary() - .small() - .icon(IconName::Plus) - .label(t!("new_connection_short").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_collapsed_connections = false; - this.open_new_ssh_dialog(window, cx); - })), - ) - .child( - pointer_button("close-collapsed-connections") - .ghost() - .xsmall() - .icon(IconName::Close) - .tooltip(t!("close_connections").to_string()) - .on_click(cx.listener(|this, _, _, cx| { - this.show_collapsed_connections = false; - cx.notify(); - })), - ), - ) - .child( - Input::new(&self.connection_filter_input) - .w_full() - .min_w(px(0.)), - ) - .child( - h_flex() - .w_full() - .items_center() - .gap_1() - .pl(px(5.)) - .py(px(4.)) - .child( - h_flex() - .w(px(16.)) - .flex_none() - .items_center() - .justify_center() - .child( - pointer_checkbox("collapsed-connections-select-all") - .small() - .checked(all_connections_selected) - .disabled(!has_connections) - .tab_stop(false) - .on_click(cx.listener({ - let visible_session_ids = visible_session_ids.clone(); - move |this, checked, _, cx| { - this.set_connection_selection( - visible_session_ids.clone(), - *checked, - cx, - ); - } - })), - ), - ) - .child( - div() - .text_size(rems(0.75)) - .text_color(theme.muted_foreground) - .child(format!("{selected_connections}/{total_connections}")), - ) - .child(div().flex_1()) - .child( - pointer_button("collapsed-new-connection-group") - .ghost() - .small() - .icon(IconName::Plus) - .tooltip(t!("new_connection_group").to_string()) - .on_click(cx.listener(|this, _, window, cx| { - this.show_collapsed_connections = false; - this.show_connection_group_dialog(None, window, cx); - })), - ) - .child({ - let groups = connection_groups.clone(); - pointer_button("collapsed-move-selected-connections") - .ghost() - .small() - .icon(IconName::FolderClosed) - .tooltip(t!("move_to_group").to_string()) - .disabled(!has_selected_connections) - .dropdown_menu_with_anchor(Anchor::BottomRight, { - let view = cx.entity(); - move |menu, window, _| { - let menu = menu.min_w(0.).item( - PopupMenuItem::new(t!("ungrouped").to_string()).on_click( - window.listener_for(&view, |this, _, _, cx| { - this.move_selected_connections_to_group( - String::new(), - cx, - ); - }), - ), - ); - groups.iter().fold(menu, |menu, group| { - let group = group.clone(); - let label = group.clone(); - menu.item(PopupMenuItem::new(label).on_click( - window.listener_for(&view, move |this, _, _, cx| { - this.move_selected_connections_to_group( - group.clone(), - cx, - ); - }), - )) - }) - } - }) - }) - .child( - pointer_button("collapsed-delete-selected-connections") - .danger() - .small() - .icon(IconName::Delete) - .label(t!("delete_selected_connections").to_string()) - .disabled(!has_selected_connections) - .on_click(cx.listener(|this, _, _, cx| { - this.remove_selected_sessions(cx); - })), - ), - ) - .child( - h_flex() - .w_full() - .h(px(estimated_list_height)) - .flex_none() - .min_h(px(0.)) - .child( - div() - .flex_1() - .min_w(px(0.)) - .h_full() - .id("collapsed-connections-scroll") - .track_scroll(&self.collapsed_saved_scroll_handle) - .overflow_y_scroll() - .on_prepaint(move |_, _, cx| { - let overflowing = - collapsed_saved_scroll_handle.max_offset().y > px(0.); - sidebar_view.update(cx, |this, cx| { - if this.collapsed_saved_sessions_overflowing != overflowing { - this.collapsed_saved_sessions_overflowing = overflowing; - cx.notify(); - } - }); - }) - .child(if has_group_sections { - v_flex() - .w_full() - .min_w(px(0.)) - .gap_2() - .children(group_sections.into_iter().map(|section| { - self.render_connection_group_section( - section, - !connection_filter.is_empty(), - active_session_id.as_deref(), - cx, - ) - })) - .into_any_element() - } else { - div() - .size_full() - .flex() - .items_center() - .justify_center() - .text_size(rems(0.833)) - .text_color(theme.muted_foreground) - .child(empty_message) - .into_any_element() - }), - ) - .when(collapsed_saved_sessions_overflowing, |this| { - this.child( - div().relative().w(px(12.)).h_full().flex_none().child( - Scrollbar::vertical(&self.collapsed_saved_scroll_handle) - .scrollbar_show(ScrollbarShow::Scrolling), - ), - ) - }), - ) - .into_any_element() - } - - fn render_collapsed_sidebar(&self, cx: &mut Context) -> impl IntoElement { - v_flex() - .w_full() - .h_full() - .min_w(px(0.)) - .p_2() - .border_r_1() - .border_color(cx.theme().sidebar_border) - .bg(cx.theme().sidebar) - .overflow_hidden() - .items_center() - .child( - div() - .w_full() - .flex() - .items_center() - .justify_center() - .pb_2() - .child( - pointer_button("sidebar-expand-toggle") - .ghost() - .icon(IconName::PanelLeftOpen) - .tooltip(t!("settings_toggle_sidebar").to_string()) - .on_click(cx.listener(|this, _, _, cx| { - this.show_collapsed_connections = false; - this.sidebar_collapsed = false; - this.config.set_sidebar_collapsed(false); - this.save_preferences_background(); - cx.notify(); - })), - ), - ) - .child( - Popover::new("collapsed-connections-popover") - .anchor(Anchor::TopLeft) - .open(self.show_collapsed_connections) - .on_open_change(cx.listener(|this, open, _, cx| { - if this.show_collapsed_connections != *open { - this.show_collapsed_connections = *open; - cx.notify(); - } - })) - .trigger( - pointer_button("collapsed-connections-trigger") - .ghost() - .small() - .icon(IconName::Network) - .tooltip(t!("connection_management").to_string()), - ) - .ml(px(40.)) - .w(px(480.)) - .p_0() - .child(self.render_collapsed_connections_popover_content(cx)), - ) - .child(div().flex_1()) - } - fn render_window_controls( &self, window: &mut Window, @@ -4183,6 +4074,7 @@ impl Ashell { } fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); let active_group_index = self .active_group .as_ref() @@ -4206,155 +4098,310 @@ impl Ashell { .iter() .map(|s| s.to_string()) .collect(); - (g.id.clone(), g.title.clone(), pane_ids) + (g.id.clone(), self.tab_group_display_name(g), pane_ids) }) .collect(); - let is_integrated = - self.active_title_bar_style == crate::session::config::TitleBarStyle::Integrated; - let active_terminal_encoding = self.active_tab.as_ref().and_then(|active_id| { - self.tabs - .iter() - .find(|tab| { - tab.id == *active_id && matches!(tab.kind, TabKind::Local | TabKind::Ssh) - }) - .map(|tab| (tab.id.clone(), tab.text_encoding())) - }); - + let tabbar_menu = { + let view = view.clone(); + let tab_entries = groups_data.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, _))| { + 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 item_view = view.clone(); + let item_menu = popup_menu.clone(); + let label = label.clone(); + menu.item( + PopupMenuItem::element(move |_, _| { + let drag_group_id = drag_group_id.clone(); + let target_group_for_style = target_group_for_style.clone(); + let drop_view = item_view.clone(); + let drop_menu = item_menu.clone(); + let drop_target = target_group_id.clone(); + h_flex() + .flex_1() + .min_w(px(0.)) + .items_center() + .id(("tab-group-drag", ix)) + .cursor_grab() + .drag_over::(move |this, drag, _, cx| { + if drag.group_id == target_group_for_style { + this + } else { + this.border_t_2() + .border_color(cx.theme().drag_border) + .bg(cx.theme().drop_target) + } + }) + .on_drag( + TabGroupDrag { + group_id: drag_group_id, + }, + |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| { + let _ = drag; + Empty + }) + }, + ) + .on_drop::(move |drag, _, cx| { + let dragged_group_id = drag.group_id.clone(); + drop_view.update(cx, |this, cx| { + this.reorder_tab_groups( + &dragged_group_id, + &drop_target, + cx, + ); + }); + drop_menu.update(cx, |_, cx| { + cx.emit(DismissEvent); + }); + }) + .child(label.clone()) + }) + .checked(ix == selected) + .on_click( + window.listener_for(&view, move |this, _, window, cx| { + this.activate_group(group_id.clone(), window, cx); + this.ensure_tab_visible(ix, window, cx); + }), + ), + ) + }, + ) + }), + ) + }; h_flex() .flex_1() .min_w(px(0.)) .h_full() + .pl(px(8.)) .items_center() - .gap_2() + .gap_1() .child( - div() + pointer_button("sidebar-toggle") + .ghost() + .small() + .icon(if self.sidebar_collapsed { + IconName::PanelLeftOpen + } else { + IconName::PanelLeftClose + }) + .tooltip(t!("settings_toggle_sidebar").to_string()) + .on_click(cx.listener(|this, _, _, cx| { + this.sidebar_collapsed = !this.sidebar_collapsed; + this.is_layout_reset = false; + this.config.set_sidebar_collapsed(this.sidebar_collapsed); + this.save_preferences_background(); + cx.notify(); + })), + ) + .child( + h_flex() .flex_1() .min_w(px(0.)) .h_full() - .when(is_integrated, |this| { - this.window_control_area(gpui::WindowControlArea::Drag) - }) - .overflow_x_hidden() - .child({ - TabBar::new("ashell-tab-bar") - .underline() - .track_scroll(&self.tabs_scroll_handle) - .selected_index(selected) - .children(groups_data.iter().enumerate().map( - |(ix, (group_id, title, pane_ids))| { - let gid = group_id.clone(); - let label = if pane_ids.len() > 1 { - format!("{} ({})", title, pane_ids.len()) - } else { - title.clone() - }; - let close_id = if self.active_group.as_ref() == Some(&gid) { - self.active_tab.clone().unwrap_or_else(|| { - pane_ids.first().cloned().unwrap_or_default() - }) - } else { - pane_ids.first().cloned().unwrap_or_default() - }; - - let dot_color = pane_ids - .first() - .and_then(|id| self.tabs.iter().find(|t| t.id == *id)) - .map(|tab| { - if tab.connected { - cx.theme().success + .gap(px(8.)) + .child( + div() + .flex_1() + .min_w(px(0.)) + .h_full() + .overflow_x_hidden() + .child({ + h_flex() + .id("ashell-tab-bar") + .relative() + .min_w(px(0.)) + .w_full() + .h_full() + .items_center() + .gap_2() + .overflow_x_scroll() + .track_scroll(&self.tabs_scroll_handle) + .on_scroll_wheel(cx.listener(|this, _, _, _| { + this.tab_scroll_animation_id = + this.tab_scroll_animation_id.wrapping_add(1); + })) + .border_b_1() + .border_color(cx.theme().border) + .children(groups_data.iter().enumerate().map( + |(ix, (group_id, title, pane_ids))| { + let gid = group_id.clone(); + let label = title.clone(); + let click_gid = gid.clone(); + let close_id = if self.active_group.as_ref() + == Some(&gid) + { + self.active_tab.clone().unwrap_or_else(|| { + pane_ids.first().cloned().unwrap_or_default() + }) } else { - cx.theme().danger - } - }) - .unwrap_or(cx.theme().success); - let output_active = pane_ids.iter().any(|id| { - self.tabs - .iter() - .find(|tab| tab.id == *id) - .is_some_and(TerminalTab::is_command_active) - }); - Tab::new() - .min_w(px(80.)) - .child( + pane_ids.first().cloned().unwrap_or_default() + }; + + let dot_color = pane_ids + .first() + .and_then(|id| { + self.tabs.iter().find(|t| t.id == *id) + }) + .map(|tab| { + if tab.connected { + cx.theme().success + } else { + cx.theme().danger + } + }) + .unwrap_or(cx.theme().success); + let output_active = pane_ids.iter().any(|id| { + self.tabs + .iter() + .find(|tab| tab.id == *id) + .is_some_and(TerminalTab::is_command_active) + }); h_flex() - .w_full() - .min_w(px(0.)) - .items_center() - .gap_2() + .id(("ashell-tab", ix)) + .relative() + .flex_none() + .h(px(30.)) + .min_w(px(112.)) + .max_w(px(220.)) + .border_b_2() + .border_color(if ix == selected { + cx.theme().primary + } else { + cx.theme().transparent + }) + .text_size(rems(0.875)) + .hover(|this| { + this.text_color( + cx.theme().tab_active_foreground, + ) + }) .child( h_flex() - .flex_none() - .size(px(12.)) - .items_center() - .justify_center() - .when(output_active, |this| { - this.child( - Spinner::new() - .xsmall() - .color(cx.theme().primary), - ) - }) - .when(!output_active, |this| { - this.child( - div() - .size(px(6.)) - .rounded_full() - .bg(dot_color), - ) - }), - ) - .child( - div() - .flex_1() + .w_full() + .h_full() .min_w(px(0.)) - .truncate() - .when(ix == selected, |this| { - this.font_weight(FontWeight::BOLD) - .text_color(cx.theme().foreground) - }) - .when(ix != selected, |this| { - this.text_color( - cx.theme().muted_foreground, - ) - }) - .child(label), - ) - .child( - pointer_button(("tab-close", ix)) - .ghost() - .xsmall() - .icon(IconName::Close) - .mr(px(5.)) - .on_mouse_down( - MouseButton::Left, - |_, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - }, - ) - .on_click(cx.listener( - move |this, _, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - if !close_id.is_empty() { - this.close_tab( - close_id.clone(), - cx, + .px_2() + .items_center() + .gap_2() + .child( + h_flex() + .flex_none() + .size(px(12.)) + .items_center() + .justify_center() + .when(output_active, |this| { + this.child( + Spinner::new() + .xsmall() + .color( + cx.theme().primary, + ), ) - } - }, - )), - ), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.activate_group(gid.clone(), window, cx) - })) - }, - )) - .last_empty_space(div().w_3()) - .w_full() - .h_full() - }), + }) + .when(!output_active, |this| { + this.child( + div() + .size(px(6.)) + .rounded_full() + .bg(dot_color), + ) + }), + ) + .child( + div() + .flex_1() + .min_w(px(0.)) + .truncate() + .when(ix == selected, |this| { + this.font_weight( + FontWeight::SEMIBOLD, + ) + .text_color( + cx.theme().foreground, + ) + }) + .when(ix != selected, |this| { + this.text_color( + cx.theme().muted_foreground, + ) + }) + .child(label), + ) + .child( + pointer_button(("tab-close", ix)) + .ghost() + .xsmall() + .icon(IconName::Close) + .opacity(if ix == selected { + 0.8 + } else { + 0.45 + }) + .hover(|style| style.opacity(1.0)) + .on_mouse_down( + MouseButton::Left, + |_, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }, + ) + .on_click(cx.listener( + move |this, _, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + if !close_id.is_empty() { + this.close_tab( + close_id.clone(), + cx, + ) + } + }, + )), + ), + ) + .cursor_pointer() + .block_mouse_except_scroll() + .on_mouse_down(MouseButton::Left, |_, window, _| { + window.prevent_default(); + }) + .on_click(cx.listener( + move |this, _, window, cx| { + this.activate_group( + click_gid.clone(), + window, + cx, + ) + }, + )) + }, + )) + }) + .on_prepaint({ + let view = view.clone(); + move |bounds, _, cx| { + view.update(cx, |this, _| { + this.tabs_viewport_bounds = Some(bounds); + }); + } + }), + ) + .child(tabbar_menu), ) .child( h_flex() @@ -4362,46 +4409,10 @@ impl Ashell { .items_center() .gap_1() .pr(px(6.)) - .when_some(active_terminal_encoding, |this, (tab_id, encoding)| { - this.child( - pointer_button("terminal-encoding") - .ghost() - .small() - .icon(IconName::Globe) - .label(encoding.label()) - .tooltip(t!("terminal_encoding").to_string()) - .dropdown_menu_with_anchor(Anchor::BottomRight, { - let view = cx.entity(); - move |menu, window, _| { - TERMINAL_ENCODINGS.iter().copied().fold( - menu.min_w(0.), - |menu, candidate| { - let tab_id = tab_id.clone(); - menu.item( - PopupMenuItem::new(candidate.label()) - .checked(candidate == encoding) - .on_click(window.listener_for( - &view, - move |this, _, _, cx| { - this.set_terminal_encoding( - tab_id.clone(), - candidate, - cx, - ); - }, - )), - ) - }, - ) - } - }), - ) - }) .child( pointer_button("open-selector") - .secondary() + .ghost() .small() - .rounded(px(999.)) .icon(IconName::Plus) .tooltip(t!("settings_open_session").to_string()) .dropdown_menu_with_anchor(Anchor::BottomRight, { @@ -4431,9 +4442,8 @@ impl Ashell { ) .child( pointer_button("split-horizontal") - .secondary() + .ghost() .small() - .rounded(px(999.)) .icon(IconName::PanelBottom) .tooltip(t!("settings_split_pane_down").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -4444,9 +4454,8 @@ impl Ashell { ) .child( pointer_button("split-vertical") - .secondary() + .ghost() .small() - .rounded(px(999.)) .icon(IconName::PanelRight) .tooltip(t!("settings_split_pane_right").to_string()) .on_click(cx.listener(|this, _, window, cx| { @@ -4455,7 +4464,17 @@ impl Ashell { this.split_current_pane("right", cx); })), ) - .child(self.render_search_button(cx)), + .child(self.render_search_button(cx)) + .child( + pointer_button("tabbar-settings") + .ghost() + .small() + .icon(IconName::Settings) + .tooltip(t!("settings_open_settings").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.show_settings_dialog(window, cx) + })), + ), ) } @@ -4913,6 +4932,7 @@ impl Ashell { .is_some_and(|hu| hu.tab_id == *tab_id); let mut el = div() .size_full() + .pl(px(8.)) .pr(px(TERMINAL_SCROLLBAR_GUTTER)) .overflow_hidden() .when(is_url_hovered, |d| d.cursor_pointer()) @@ -4949,7 +4969,7 @@ impl Ashell { let scrollbar = this.terminal_scrollbars.entry(tab_id.clone()).or_default(); el = el.vertical_scrollbar(scrollbar); - // When disconnected, overlay a reconnect bar at the bottom of the terminal. + // When disconnected, overlay a reconnect bar at the top of the terminal. // Uses absolute positioning so the terminal element itself is unchanged, // keeping panel size stable in multi-panel layouts. let disconnected_reason = this @@ -4960,7 +4980,7 @@ impl Ashell { if let Some(reason) = disconnected_reason { let tab_id_for_reconnect = tab_id.clone(); el = div().size_full().relative().child(el).child( - div().absolute().bottom_0().left_0().right_0().child( + div().absolute().top_0().left_0().right_0().child( h_flex() .w_full() .items_center() @@ -4968,6 +4988,7 @@ impl Ashell { .px_3() .py_1() .bg(cx.theme().danger.opacity(0.15)) + .cursor_pointer() .child( div() .text_size(rems(0.85)) @@ -5206,6 +5227,11 @@ impl Render for Ashell { let has_ssh_session = self.active_ssh_session().is_some(); let is_monitor_bottom = self.config.monitoring_position() == "Bottom"; + let is_active_ssh_connected = self + .active_tab + .as_ref() + .and_then(|active_id| self.tabs.iter().find(|tab| tab.id == *active_id)) + .is_some_and(|tab| tab.kind == TabKind::Ssh && tab.connected); let viewport_width = window.viewport_size().width; let body_panel = if has_ssh_session { @@ -5224,25 +5250,38 @@ impl Render for Ashell { }; let view = cx.entity(); - 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))) + v_flex() + .size_full() .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().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)), + ), + ), ) + .when(is_monitor_bottom, |this| { + this.child(self.render_monitoring_panel( + viewport_width, + is_active_ssh_connected, + cx, + )) + }) .into_any_element() } else { v_flex() @@ -5254,46 +5293,32 @@ impl Render for Ashell { .child(self.render_terminal_panel(window, cx)), ) .when(is_monitor_bottom, |this| { - this.child(self.render_monitoring_panel(viewport_width, cx)) + this.child(self.render_monitoring_panel(viewport_width, false, cx)) }) .into_any_element() }; let workspace = if self.sidebar_collapsed { - h_flex() + v_flex() .size_full() - .child( - div() - .flex_none() - .w(px(COLLAPSED_SIDEBAR_WIDTH)) - .h_full() - .child(self.render_collapsed_sidebar(cx)), - ) - .child( - div().flex_1().h_full().min_w(px(0.)).child( - v_flex() - .size_full() - .relative() - .overflow_hidden() - .when( - self.active_title_bar_style - == crate::session::config::TitleBarStyle::Native, - |this| { - this.child( - div() - .flex_none() - .h(px(32.)) - .w_full() - .bg(cx.theme().tab_bar) - .border_b_1() - .border_color(cx.theme().border) - .child(self.render_tab_bar(cx)), - ) - }, - ) - .child(body_panel), - ), + .relative() + .overflow_hidden() + .when( + self.active_title_bar_style == crate::session::config::TitleBarStyle::Native, + |this| { + this.child( + div() + .flex_none() + .h(px(32.)) + .w_full() + .bg(cx.theme().tab_bar) + .border_b_1() + .border_color(cx.theme().border) + .child(self.render_tab_bar(cx)), + ) + }, ) + .child(body_panel) .into_any_element() } else { let sidebar_area = resizable_panel() @@ -5350,6 +5375,25 @@ impl Render for Ashell { .bg(cx.theme().background) .text_color(cx.theme().foreground) .font_family(self.ui_font_family.clone()) + .on_action(cx.listener(|this, _: &crate::AboutAshell, window, cx| { + this.show_about_dialog(window, cx); + })) + .on_action(cx.listener(|this, _: &crate::NewLocalTerminal, _, cx| { + this.open_local(cx); + })) + .on_action(cx.listener(|this, _: &crate::CloseWindow, window, cx| { + this.save_layout_state(window, cx); + window.remove_window(); + })) + .on_action(cx.listener(|_, _: &crate::MinimizeWindow, window, _| { + window.minimize_window(); + })) + .on_action(cx.listener(|_, _: &crate::ZoomWindow, window, _| { + window.zoom_window(); + })) + .on_action(cx.listener(|_, _: &crate::ToggleFullScreen, window, _| { + window.toggle_fullscreen(); + })) .on_action(cx.listener(|this, _: &crate::OpenSettings, window, cx| this.show_settings_dialog(window, cx))) .on_action(cx.listener(|this, _: &crate::OpenSession, window, cx| this.show_selector_dialog(window, cx))) .on_action(cx.listener(|this, _: &crate::OpenTransfers, window, cx| this.show_transfers_dialog(window, cx))) @@ -5357,9 +5401,6 @@ impl Render for Ashell { .on_action(cx.listener(|this, _: &crate::OpenSearch, window, cx| this.toggle_search(window, cx))) .on_action(cx.listener(|this, _: &crate::ToggleSidebar, _, cx| { this.sidebar_collapsed = !this.sidebar_collapsed; - if !this.sidebar_collapsed { - this.show_collapsed_connections = false; - } this.is_layout_reset = false; this.config.set_sidebar_collapsed(this.sidebar_collapsed); this.save_preferences_background(); @@ -5394,7 +5435,7 @@ impl Render for Ashell { cx.stop_propagation(); } } else { - cx.propagate(); + window.dispatch_action(Box::new(gpui_component::input::Copy), cx); } })) .on_action(cx.listener(|this, _: &crate::Paste, window, cx| { @@ -5405,7 +5446,7 @@ impl Render for Ashell { } } } else { - cx.propagate(); + window.dispatch_action(Box::new(gpui_component::input::Paste), cx); } })) .when(self.active_title_bar_style == crate::session::config::TitleBarStyle::Integrated, |this| { @@ -5417,10 +5458,12 @@ impl Render for Ashell { .h(px(34.)) .w_full() .bg(cx.theme().tab_bar) + .border_b_1() + .border_color(cx.theme().border) .child(self.render_window_controls(window, cx)) .child( div() - .id("tab-bar-drag") + .id("title-bar-content") .flex_1() .min_w(px(0.)) .h_full() @@ -5430,29 +5473,6 @@ impl Render for Ashell { #[cfg(not(target_os = "macos"))] window.zoom_window(); }) - .when(cfg!(target_os = "linux"), |this| { - this.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_down_out(cx.listener(|this, _, _, _| { - this.should_move_window = false; - })) - .on_mouse_move(cx.listener(|this, _, window, _| { - if this.should_move_window { - this.should_move_window = false; - window.start_window_move(); - } - })) - }) .child(self.render_tab_bar(cx)), ), ) diff --git a/src/main.rs b/src/main.rs index e47874a..584eee9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,9 @@ pub(crate) use app::keybinding_recorder::{ OpenSession, OpenSettings, OpenTransfers, Paste, QuitApplication, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp, ToggleSftpZoom, ToggleSidebar, }; +pub(crate) use app::system_menu::{ + AboutAshell, CloseWindow, MinimizeWindow, NewLocalTerminal, ToggleFullScreen, ZoomWindow, +}; pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup}; @@ -45,6 +48,7 @@ fn main() { }); app.run(move |cx| { gpui_component::init(cx); + app::system_menu::init(cx); cx.on_action(|_: &QuitApplication, cx| cx.quit()); cx.bind_keys([ diff --git a/src/session/config.rs b/src/session/config.rs index 32833de..5f2ea1c 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -271,6 +271,8 @@ pub struct ConfigFile { pub locale: String, #[serde(default = "default_terminal_font_size")] pub terminal_font_size: f32, + #[serde(default)] + pub local_terminal_encoding: TextEncoding, #[serde(default = "default_ui_font_size")] pub ui_font_size: f32, #[serde(default)] @@ -446,6 +448,7 @@ impl Default for ConfigFile { dark_theme_name: String::new(), locale: default_locale(), terminal_font_size: default_terminal_font_size(), + local_terminal_encoding: TextEncoding::Utf8, ui_font_size: default_ui_font_size(), right_click_copy_paste: false, keyword_highlight: false, @@ -1249,6 +1252,14 @@ impl ConfigStore { self.cache.terminal_font_size = terminal_font_size.max(10.0); } + pub fn local_terminal_encoding(&self) -> TextEncoding { + self.cache.local_terminal_encoding + } + + pub fn set_local_terminal_encoding(&mut self, encoding: TextEncoding) { + self.cache.local_terminal_encoding = encoding; + } + pub fn ui_font_size(&self) -> f32 { if self.cache.ui_font_size <= 0.0 { default_ui_font_size() @@ -1468,6 +1479,7 @@ impl ConfigStore { disk_config.dark_theme_name = local_config.dark_theme_name; disk_config.locale = local_config.locale; disk_config.terminal_font_size = local_config.terminal_font_size; + disk_config.local_terminal_encoding = local_config.local_terminal_encoding; disk_config.ui_font_size = local_config.ui_font_size; disk_config.right_click_copy_paste = local_config.right_click_copy_paste; disk_config.keyword_highlight = local_config.keyword_highlight; @@ -1876,6 +1888,7 @@ mod tests { assert!(config.sftp_file_columns.is_none()); assert!(!config.sftp_file_columns_customized); assert!(config.connection_groups.is_empty()); + assert_eq!(config.local_terminal_encoding, TextEncoding::Utf8); assert!(config.collapsed_connection_groups.is_empty()); } @@ -2020,6 +2033,7 @@ mod tests { let mut local_config = ConfigFile { ui_font_size: 18.0, terminal_font_size: 20.0, + local_terminal_encoding: TextEncoding::Gbk, show_hidden_files: true, sftp_file_columns_customized: true, remember_tabs: true, @@ -2057,6 +2071,7 @@ mod tests { assert_eq!(decrypted.ui_font_size, 18.0); assert_eq!(decrypted.terminal_font_size, 20.0); + assert_eq!(decrypted.local_terminal_encoding, TextEncoding::Gbk); assert!(decrypted.show_hidden_files); assert!(decrypted.sftp_file_columns_customized); assert!(decrypted.remember_tabs); diff --git a/src/session/mod.rs b/src/session/mod.rs index 585c830..3e566ea 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -9,6 +9,7 @@ use gpui::{ }; use gpui_component::{Theme, WindowExt as _, input::InputState}; use rust_i18n::t; +use std::time::Duration; use uuid::Uuid; use self::config::{ @@ -99,6 +100,7 @@ fn connecting_sftp_state() -> crate::terminal::SftpUiState { loading_directories: std::collections::HashSet::new(), directory_errors: std::collections::HashMap::new(), selected_path: None, + selection_anchor: None, preview: None, selected_entries: std::collections::HashSet::new(), home_dir: "/".into(), @@ -455,6 +457,66 @@ impl Ashell { cx.notify(); } + pub(crate) fn resize_terminal_for_layout( + &mut self, + tab_id: &str, + cols: u16, + rows: u16, + cx: &mut Context, + ) -> Option { + let tab_index = self.tabs.iter().position(|tab| tab.id == tab_id)?; + let cols = cols.max(1); + let rows = rows.max(1); + + if self.tabs[tab_index].kind != TabKind::Local { + let keyword_highlight = self.config.keyword_highlight(); + if self.tabs[tab_index].resize(cols, rows) { + return Some(self.tabs[tab_index].render_snapshot(keyword_highlight)); + } + return None; + } + + if self.tabs[tab_index].cols == cols && self.tabs[tab_index].rows == rows { + self.pending_local_terminal_resizes.remove(tab_id); + if self.pending_local_terminal_resizes.is_empty() { + self.local_terminal_resize_task = None; + } + return None; + } + + let requested_size = (cols, rows); + if self.pending_local_terminal_resizes.get(tab_id) == Some(&requested_size) { + return None; + } + self.pending_local_terminal_resizes + .insert(tab_id.to_string(), requested_size); + + self.local_terminal_resize_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(80)) + .await; + let _ = this.update(cx, |this, cx| { + let pending = std::mem::take(&mut this.pending_local_terminal_resizes); + this.local_terminal_resize_task = None; + let mut resized = false; + for (tab_id, (cols, rows)) in pending { + if let Some(tab) = this + .tabs + .iter_mut() + .find(|tab| tab.id == tab_id && tab.kind == TabKind::Local) + { + resized |= tab.resize(cols, rows); + } + } + if resized { + cx.notify(); + } + }); + })); + + None + } + pub(crate) fn open_local(&mut self, cx: &mut Context) { let previous_active_tab = self.active_tab.clone(); let id = Uuid::new_v4().to_string(); @@ -473,6 +535,7 @@ impl Ashell { let mut tab = TerminalTab::new_local(id.clone(), title.clone(), backend, backend_events); tab.local_cwd = initial_directory; + tab.set_text_encoding(self.config.local_terminal_encoding()); tab.resize(DEFAULT_COLS, DEFAULT_ROWS); self.tabs.push(tab); self.active_tab = Some(id.clone()); @@ -789,7 +852,6 @@ impl Ashell { window: &mut Window, cx: &mut Context, ) { - self.show_collapsed_connections = false; let Some(session) = self.config.get(&session_id).cloned() else { self.status = "saved session not found".into(); cx.notify(); @@ -805,7 +867,6 @@ impl Ashell { window: &mut Window, cx: &mut Context, ) { - self.show_collapsed_connections = false; let Some(session) = self.config.get(&session_id).cloned() else { self.status = "saved session not found".into(); cx.notify(); @@ -960,6 +1021,36 @@ impl Ashell { cx.notify(); } + pub(crate) fn change_local_terminal_encoding( + &mut self, + encoding: TextEncoding, + cx: &mut Context, + ) { + if self.config.local_terminal_encoding() == encoding { + return; + } + + self.config.set_local_terminal_encoding(encoding); + for tab in self + .tabs + .iter_mut() + .filter(|tab| tab.kind == TabKind::Local) + { + tab.set_text_encoding(encoding); + } + if self.config.remember_tabs() { + self.capture_tabs_state(); + } + self.save_preferences_background(); + self.status = t!( + "local_terminal_encoding_changed", + encoding = encoding.label() + ) + .to_string() + .into(); + cx.notify(); + } + pub(crate) fn refresh_ssh_config(&mut self) { self.ssh_config_entries = crate::session::ssh_config::parse_ssh_config().unwrap_or_default(); @@ -1010,7 +1101,6 @@ impl Ashell { } pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context) { - self.show_collapsed_connections = false; tracing::info!( "[ui] user clicked to connect saved session '{}'", session_id @@ -1846,14 +1936,6 @@ impl Ashell { .map(|tab| tab.kind) } - pub(crate) fn active_title(&self) -> String { - self.active_tab - .as_ref() - .and_then(|id| self.tabs.iter().find(|t| &t.id == id)) - .map(|t| t.title.clone()) - .unwrap_or_else(|| t!("idle_no_session").into()) - } - pub(crate) fn active_ssh_session(&self) -> Option<(String, Session)> { let active_id = self.active_tab.as_ref()?; let tab = self.tabs.iter().find(|tab| &tab.id == active_id)?; @@ -2204,6 +2286,40 @@ impl Ashell { cx.notify(); } + pub(crate) fn reorder_tab_groups( + &mut self, + dragged_group_id: &str, + target_group_id: &str, + cx: &mut Context, + ) { + if dragged_group_id == target_group_id { + return; + } + + let Some(from_index) = self + .tab_groups + .iter() + .position(|group| group.id == dragged_group_id) + else { + return; + }; + let Some(mut target_index) = self + .tab_groups + .iter() + .position(|group| group.id == target_group_id) + else { + return; + }; + + let dragged_group = self.tab_groups.remove(from_index); + if from_index < target_index { + target_index -= 1; + } + self.tab_groups.insert(target_index, dragged_group); + self.save_tabs_state_background(); + cx.notify(); + } + pub(crate) fn sync_pane_root_to_group(&mut self) { if let Some(group_id) = self.active_group.clone() { if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) { diff --git a/src/sftp/ops.rs b/src/sftp/ops.rs index fcd1432..3f60858 100644 --- a/src/sftp/ops.rs +++ b/src/sftp/ops.rs @@ -46,6 +46,7 @@ impl Ashell { if let Some(sftp) = self.active_sftp_mut() { sftp.current_path = path.clone(); sftp.selected_path = None; + sftp.selection_anchor = None; sftp.preview = None; sftp.selected_entries.clear(); sftp.expand_to(&path); @@ -134,13 +135,48 @@ impl Ashell { } } - pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context) { + pub(crate) fn select_sftp_entry( + &mut self, + entry: RemoteEntry, + visible_paths: &[String], + extend_range: bool, + cx: &mut Context, + ) { + if extend_range { + if let Some(sftp) = self.active_sftp_mut() { + let target_path = entry.full_path.clone(); + let target_index = visible_paths.iter().position(|path| path == &target_path); + let anchor_index = sftp + .selection_anchor + .as_ref() + .and_then(|anchor| visible_paths.iter().position(|path| path == anchor)); + + sftp.selected_entries.clear(); + if let Some(target_index) = target_index { + let anchor_index = anchor_index.unwrap_or(target_index); + let start = anchor_index.min(target_index); + let end = anchor_index.max(target_index); + sftp.selected_entries + .extend(visible_paths[start..=end].iter().cloned()); + } else { + sftp.selected_entries.insert(target_path.clone()); + } + if sftp.selection_anchor.is_none() { + sftp.selection_anchor = Some(target_path.clone()); + } + sftp.selected_path = Some(target_path); + } + cx.notify(); + return; + } + if entry.is_dir { self.navigate_sftp(entry.full_path, cx); return; } self.mark_sftp_entry_selected(&entry.full_path, cx); if let Some(sftp) = self.active_sftp_mut() { + sftp.selection_anchor = Some(entry.full_path.clone()); if !sftp.selected_entries.remove(&entry.full_path) { sftp.selected_entries.insert(entry.full_path); } @@ -381,6 +417,8 @@ impl Ashell { cx: &mut Context, ) { if let Some(sftp) = self.active_sftp_mut() { + sftp.selected_path = Some(path.clone()); + sftp.selection_anchor = Some(path.clone()); if checked { sftp.selected_entries.insert(path); } else { @@ -403,6 +441,8 @@ impl Ashell { } } else { sftp.selected_entries.clear(); + sftp.selected_path = None; + sftp.selection_anchor = None; } cx.notify(); } diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 23aa8d6..8228a8e 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -614,32 +614,26 @@ impl Element for TerminalElement { cx: &mut App, ) -> Self::PrepaintState { let _ = self.base_text_style(cx); - let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx); - // Save the precise GPUI-rendered bounds of this terminal element. - // This is 100% accurate because it is recorded during layout prepaint. + // Local window animations emit many intermediate sizes. The view + // coalesces those requests before resizing its terminal grid and PTY. let view = self.view.clone(); let tab_id = self.tab_id.clone(); - view.update(cx, |this, cx| { - let old_bounds = this.terminal_bounds.insert(tab_id.clone(), bounds); - - // Sync PTY size unconditionally on every prepaint layout pass to ensure - // absolute synchronization with GPUI layout regardless of intermediate events. - let line_height = this.terminal_line_height(); - let cell_width = this.terminal_cell_width(); - let w = bounds.size.width.as_f32(); - let h = bounds.size.height.as_f32(); - let cols = (w / cell_width).floor().max(1.0) as u16; - let rows = (h / line_height).floor().max(1.0) as u16; - - if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == tab_id) { - tab.resize(cols, rows); - } - - if old_bounds != Some(bounds) { - cx.notify(); - } + let cols = (bounds.size.width.as_f32() / self.cell_width.as_f32()) + .floor() + .max(1.0) as u16; + let rows = (bounds.size.height.as_f32() / self.line_height.as_f32()) + .floor() + .max(1.0) as u16; + let resized_snapshot = view.update(cx, |this, cx| { + this.terminal_bounds.insert(tab_id.clone(), bounds); + this.resize_terminal_for_layout(&tab_id, cols, rows, cx) }); + if let Some(snapshot) = resized_snapshot { + self.snapshot = snapshot; + } + + let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx); PrepaintState { bounds, diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index de80c27..1b68a44 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -19,11 +19,7 @@ use alacritty_terminal::{ grid::{Dimensions, Scroll}, index::{Column, Line, Point, Side}, selection::{Selection, SelectionRange, SelectionType}, - term::{ - Config, Term, TermMode, - cell::{Cell, Flags}, - point_to_viewport, viewport_to_point, - }, + term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point}, vte::ansi::{CursorShape, Processor}, }; use base64::{Engine as _, engine::general_purpose}; @@ -891,6 +887,7 @@ pub struct SftpUiState { pub loading_directories: HashSet, pub directory_errors: HashMap, pub selected_path: Option, + pub selection_anchor: Option, pub preview: Option, pub selected_entries: HashSet, pub home_dir: String, @@ -1626,7 +1623,7 @@ impl TerminalTab { next } - pub fn resize(&mut self, cols: u16, rows: u16) { + pub fn resize(&mut self, cols: u16, rows: u16) -> bool { let new_cols = cols.max(1); let new_rows = rows.max(1); if self.cols != new_cols || self.rows != new_rows { @@ -1639,6 +1636,9 @@ impl TerminalTab { ); self.term.resize(TerminalSize::new(self.cols, self.rows)); self.send_backend(BackendCommand::Resize { cols, rows }); + true + } else { + false } }