Compare commits

..
Author SHA1 Message Date
TomZz c65354e8d6 fix(ui): resolve terminal split-screen sizing alignment and remainder margin gap 2026-06-29 17:13:35 +08:00
TomZz fcfbefe3ab fix: do not label Maple Mono as built-in if loaded from system 2026-06-27 18:32:50 +08:00
TomZz 39d369aaaf fix: conditionally load built-in fonts if not present in system (resolves #65) 2026-06-27 18:28:25 +08:00
TomZz 5c0c0cc701 fix: default empty or missing session proxy type to 'none' 2026-06-27 18:23:46 +08:00
TomZz 99b7fe30e4 fix: restrict terminal copy/paste actions to terminal focus 2026-06-27 18:21:28 +08:00
TomZz 24f817f9e0 revert: remove manual keyboard-interactive authentication settings and dialog 2026-06-27 18:13:37 +08:00
TomZz 1a3f638004 fix: support bracketed paste mode (fixes #62) 2026-06-27 11:48:17 +08:00
TomZz 5f318f66c8 fix: draw background rectangle for cells with INVERSE flag (fixes #66) 2026-06-27 11:44:29 +08:00
TomZz 8854ee9afb fix: restore connection progress dialog and support Enter to retry 2026-06-27 00:37:33 +08:00
TomZz 8cc64acdc1 fix: smooth terminal font zooming using scroll accumulator 2026-06-27 00:23:20 +08:00
peace-young 77a0380ba7 feat: add Ctrl+scroll to zoom terminal font size (#64) 2026-06-27 00:08:23 +08:00
TomZz 3770826764 bump version to v0.4.8 2026-06-26 11:41:23 +08:00
TomZz cae6323888 fix: reduce keepalive interval and max attempts for faster network drop detection 2026-06-26 11:35:13 +08:00
TomZz 54bcc97fe1 fix: restore graceful terminal exit tab closure 2026-06-26 11:29:50 +08:00
phygerr 273bc2fe0c feature:1、支持断开重连功能;2、支持更多高亮关键词 (#59) 2026-06-26 11:07:25 +08:00
TomZz f0e02e0ce7 fix: resolve sftp connection timeout and disconnect handling (resolves #57) 2026-06-25 03:32:31 +08:00
TomZz f6d9cd00e7 feat: add global proxy settings and startup env reader (resolves #53) 2026-06-25 03:20:33 +08:00
TomZz bfc7c9ac83 feat: support keybinding configuration for terminal copy/paste and update hint text 2026-06-25 02:33:34 +08:00
TomZz fd19daedad feat: allow left-click to copy terminal selection text 2026-06-25 02:28:26 +08:00
TomZz 042834c7d5 feat: support custom terminal cursor style and blinking setting 2026-06-25 02:25:32 +08:00
TomZz 3c6b9e3b46 fix: correct active window check logic to fix window controls greying out when app loses focus 2026-06-24 18:06:52 +08:00
TomZz a88379b01c fix: prevent sidebar from flashing and resizing on window resize / fullscreen 2026-06-24 18:01:40 +08:00
TomZz f322b2c717 fix: beautify window control traffic lights on Windows/Linux 2026-06-24 17:46:57 +08:00
TomZz 45d0c64c06 fix: resolve gpui window not found on exit and restore layout saving on Windows 2026-06-24 17:25:25 +08:00
TomZz 6312a482cb fix: preserve original windowed bounds when saving maximized state to fix restore size on startup 2026-06-24 17:12:33 +08:00
TomZz 8bb4cbc655 fix: register WindowControlArea for window controls on non-macos to support restore toggling 2026-06-24 16:58:22 +08:00
22 changed files with 1918 additions and 866 deletions
Generated
+2 -1
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.7"
version = "0.4.8"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -355,6 +355,7 @@ dependencies = [
"thiserror 1.0.69",
"time",
"tokio",
"tokio-socks",
"tracing",
"tracing-appender",
"tracing-subscriber",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.7"
version = "0.4.8"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -9,6 +9,7 @@ rust-version = "1.85.0"
[dependencies]
anyhow = "1"
tokio-socks = "0.5"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
+27 -2
View File
@@ -62,11 +62,22 @@ add_ssh: "+ ssh"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
proxy: "Proxy"
proxy_none: "Follow Global"
proxy_host: "Proxy Host"
proxy_port: "Proxy Port"
proxy_user: "Proxy Username (optional)"
proxy_password: "Proxy Password (optional)"
ui_font_size: "UI Font Size"
terminal_font_size: "Terminal Font Size"
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
cursor_style: "Cursor Style"
cursor_style_default: "Default"
cursor_style_blink: "Blink"
cursor_style_beam: "Line"
cursor_style_beam_blink: "Line Blink"
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
@@ -76,6 +87,8 @@ local_terminal_opened: "local terminal opened"
connecting: "Connecting"
starting_connection: "starting connection..."
connection_failed: "Connection Failed"
session_disconnected: "Session disconnected: %{reason}"
press_enter_to_reconnect: "Press Enter to reconnect"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
@@ -112,7 +125,9 @@ software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
keyword_highlight: "Terminal Keyword Highlighting"
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
copy_paste_hint: "Selected text can also be copied by left-clicking."
settings_copy: "Copy Selection"
settings_paste: "Paste Clipboard"
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
@@ -183,4 +198,14 @@ sync_downloading: "Downloading and decrypting configuration..."
sync_upload_complete: "Configuration uploaded"
sync_download_complete: "Configuration downloaded"
sync_failed: "Synchronization failed"
keyboard_interactive: "Keyboard Interactive"
settings_proxy: "Proxy"
enable_proxy: "Enable Proxy"
read_env_proxy: "Read environment variables on startup"
read_env_proxy_desc: "If enabled, the global proxy settings below will be overridden by environment variables on startup."
global_proxy_settings: "Global Proxy Configuration"
global_proxy_type: "Proxy Type"
global_proxy_host: "Proxy Host"
global_proxy_port: "Proxy Port"
global_proxy_user: "Proxy Username"
global_proxy_password: "Proxy Password"
save_proxy: "Save Proxy"
+27 -2
View File
@@ -63,11 +63,22 @@ add_ssh: "+ SSH"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
proxy: "代理"
proxy_none: "跟随全局"
proxy_host: "代理服务器地址"
proxy_port: "端口"
proxy_user: "用户名 (可选)"
proxy_password: "密码 (可选)"
ui_font_size: "界面字体大小"
terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
cursor_style: "光标样式"
cursor_style_default: "默认"
cursor_style_blink: "闪烁"
cursor_style_beam: "细线"
cursor_style_beam_blink: "细线闪烁"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
@@ -77,6 +88,8 @@ local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
session_disconnected: "会话已断开: %{reason}"
press_enter_to_reconnect: "按 Enter 重新连接"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
@@ -113,7 +126,9 @@ software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
keyword_highlight: "终端关键词高亮"
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
@@ -185,4 +200,14 @@ sync_downloading: "正在下载并解密配置..."
sync_upload_complete: "配置上传完成"
sync_download_complete: "配置下载完成"
sync_failed: "同步失败"
keyboard_interactive: "键盘交互"
settings_proxy: "代理设置"
enable_proxy: "启用代理功能"
read_env_proxy: "启动时读取环境变量"
read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。"
global_proxy_settings: "全局代理配置"
global_proxy_type: "代理类型"
global_proxy_host: "代理地址"
global_proxy_port: "代理端口"
global_proxy_user: "代理用户名"
global_proxy_password: "代理密码"
save_proxy: "保存代理设置"
+5
View File
@@ -2,7 +2,12 @@ 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;
#[allow(dead_code)]
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+248 -129
View File
@@ -1,5 +1,5 @@
use gpui::{
Anchor, AppContext as _, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
prelude::FluentBuilder as _, px, rems,
};
@@ -8,7 +8,7 @@ use gpui_component::{
button::{Button, ButtonVariants as _},
dialog::Dialog,
h_flex,
input::{Input, InputState},
input::Input,
menu::{DropdownMenu as _, PopupMenuItem},
progress::Progress,
scroll::{Scrollbar, ScrollbarShow},
@@ -36,6 +36,10 @@ impl Ashell {
let key_path_input = self.key_path_input.clone();
let key_inline_input = self.key_inline_input.clone();
let passphrase_input = self.passphrase_input.clone();
let proxy_host_input = self.proxy_host_input.clone();
let proxy_port_input = self.proxy_port_input.clone();
let proxy_user_input = self.proxy_user_input.clone();
let proxy_password_input = self.proxy_password_input.clone();
window.open_dialog(cx, move |dialog: Dialog, _window, _cx| {
dialog
@@ -61,12 +65,15 @@ impl Ashell {
let key_path_input = key_path_input.clone();
let key_inline_input = key_inline_input.clone();
let passphrase_input = passphrase_input.clone();
let proxy_host_input = proxy_host_input.clone();
let proxy_port_input = proxy_port_input.clone();
let proxy_user_input = proxy_user_input.clone();
let proxy_password_input = proxy_password_input.clone();
move |content, window, cx| {
let method = view.read(cx).ssh_auth_method;
let is_password = method == AuthMethod::Password;
let is_key = method == AuthMethod::Key;
let is_kb = method == AuthMethod::KeyboardInteractive;
let is_password = view.read(cx).ssh_auth_method == AuthMethod::Password;
let is_editing = view.read(cx).editing_session_id.is_some();
let proxy_type = view.read(cx).ssh_proxy_type.clone();
let show_proxy_fields = proxy_type != "none";
content.child(
v_flex()
.gap_3()
@@ -92,13 +99,13 @@ impl Ashell {
AuthMethod::Password,
cx,
)
},
)),
},
)),
)
.child(
Button::new("ssh-auth-key")
.label(t!("key").to_string())
.when(is_key, |button| button.primary())
.when(!is_password, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
@@ -106,22 +113,8 @@ impl Ashell {
AuthMethod::Key,
cx,
)
},
)),
)
.child(
Button::new("ssh-auth-kb")
.label(t!("keyboard_interactive").to_string())
.when(is_kb, |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_auth_method(
AuthMethod::KeyboardInteractive,
cx,
)
},
)),
},
)),
),
)
.when(is_password, |this| {
@@ -129,7 +122,7 @@ impl Ashell {
Input::new(&password_input).mask_toggle().tab_index(4),
)
})
.when(is_key, |this| {
.when(!is_password, |this| {
this.child(
h_flex()
.gap_2()
@@ -170,6 +163,60 @@ impl Ashell {
.child(Input::new(&key_inline_input).h(px(128.)).tab_index(5))
.child(Input::new(&passphrase_input).mask_toggle().tab_index(6))
})
.child(
div().text_sm().font_weight(FontWeight::BOLD).child(t!("proxy").to_string())
)
.child(
h_flex()
.gap_2()
.child(
Button::new("proxy-none")
.label(t!("proxy_none").to_string())
.when(proxy_type == "none", |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type("none".to_string(), cx)
},
)),
)
.child(
Button::new("proxy-socks5")
.label("SOCKS5")
.when(proxy_type == "socks5", |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type("socks5".to_string(), cx)
},
)),
)
.child(
Button::new("proxy-http")
.label("HTTP")
.when(proxy_type == "http", |button| button.primary())
.on_click(window.listener_for(
&view,
|this, _, _, cx| {
this.set_ssh_proxy_type("http".to_string(), cx)
},
)),
)
)
.when(show_proxy_fields, |this| {
this.child(
h_flex()
.gap_2()
.child(Input::new(&proxy_host_input).flex_1())
.child(Input::new(&proxy_port_input).w(px(96.)))
)
.child(
h_flex()
.gap_2()
.child(Input::new(&proxy_user_input).flex_1())
.child(Input::new(&proxy_password_input).flex_1())
)
})
.child(
h_flex()
.justify_end()
@@ -1419,9 +1466,10 @@ impl Ashell {
.label({
let current = view.read(cx).ui_font_family.to_string();
let names = cx.text_system().all_font_names();
let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed);
if current == *".SystemUIFont" || current.is_empty() || !names.contains(&current) {
t!("system_default").to_string()
} else if current == "Maple Mono NF CN" {
} else if !using_system_maple && current == "Maple Mono NF CN" {
format!("Maple Mono NF CN ({})", t!("software_builtin"))
} else {
current
@@ -1441,7 +1489,8 @@ impl Ashell {
}))
);
let maple_font = "Maple Mono NF CN".to_string();
if names.contains(&maple_font) {
let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed);
if !using_system_maple && names.contains(&maple_font) {
names.retain(|n| n != &maple_font);
menu = menu.item(
PopupMenuItem::new(format!("{} ({})", maple_font, t!("software_builtin")))
@@ -1480,7 +1529,8 @@ impl Ashell {
.icon(IconName::ChevronsUpDown)
.label({
let current = view.read(cx).terminal_font_family.to_string();
if current == "Maple Mono NF CN" {
let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed);
if !using_system_maple && current == "Maple Mono NF CN" {
format!("Maple Mono NF CN ({})", t!("software_builtin"))
} else {
current
@@ -1493,7 +1543,8 @@ impl Ashell {
let mut names = cx.text_system().all_font_names();
menu = menu.min_w(200.).max_h(px(320.)).scrollable(true);
let maple_font = "Maple Mono NF CN".to_string();
if names.contains(&maple_font) {
let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed);
if !using_system_maple && names.contains(&maple_font) {
names.retain(|n| n != &maple_font);
menu = menu.item(
PopupMenuItem::new(format!("{} ({})", maple_font, t!("software_builtin")))
@@ -1521,6 +1572,58 @@ impl Ashell {
})
)
)
.item(
SettingItem::new(
t!("cursor_style").to_string(),
SettingField::render({
let view = view_clone_for_general.clone();
move |_, _window, cx| {
use crate::session::config::CursorStyle;
let current = view.read(cx).cursor_style;
Button::new("cursor-style-dropdown")
.small()
.icon(IconName::ChevronsUpDown)
.label(match current {
CursorStyle::Default => t!("cursor_style_default").to_string(),
CursorStyle::Blink => t!("cursor_style_blink").to_string(),
CursorStyle::Beam => t!("cursor_style_beam").to_string(),
CursorStyle::BeamBlink => t!("cursor_style_beam_blink").to_string(),
})
.dropdown_menu_with_anchor(Anchor::BottomRight, {
let view = view.clone();
move |mut menu, window, cx| {
use crate::session::config::CursorStyle;
let current = view.read(cx).cursor_style;
menu = menu.min_w(160.).max_h(px(320.)).scrollable(true);
for style in [
CursorStyle::Default,
CursorStyle::Blink,
CursorStyle::Beam,
CursorStyle::BeamBlink,
] {
let checked = style == current;
let label = match style {
CursorStyle::Default => t!("cursor_style_default").to_string(),
CursorStyle::Blink => t!("cursor_style_blink").to_string(),
CursorStyle::Beam => t!("cursor_style_beam").to_string(),
CursorStyle::BeamBlink => t!("cursor_style_beam_blink").to_string(),
};
menu = menu.item(
PopupMenuItem::new(label)
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, _window, cx| {
this.change_cursor_style(style, cx);
}))
);
}
menu
}
})
.into_any_element()
}
})
)
)
)
.group(
SettingGroup::new()
@@ -1542,7 +1645,7 @@ impl Ashell {
.into_any_element()
}
})
).description(t!("copy_paste_hint", key = if cfg!(target_os = "macos") { "Command" } else { "Ctrl" }).to_string())
).description(t!("copy_paste_hint").to_string())
)
.item(
SettingItem::new(
@@ -1765,6 +1868,119 @@ impl Ashell {
}))
)
)
.page(
SettingPage::new(t!("settings_proxy").to_string())
.icon(IconName::Network)
.group(
SettingGroup::new()
.title(t!("settings_proxy").to_string())
.item(
SettingItem::new(
t!("enable_proxy").to_string(),
SettingField::render({
let view = view.clone();
move |_, window, cx| {
Switch::new("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);
let _ = this.config.save();
cx.notify();
}))
.into_any_element()
}
})
)
)
.item(
SettingItem::new(
t!("read_env_proxy").to_string(),
SettingField::render({
let view = view.clone();
move |_, window, cx| {
Switch::new("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);
let _ = this.config.save();
cx.notify();
}))
.into_any_element()
}
})
).description(t!("read_env_proxy_desc").to_string())
)
.item(SettingItem::render({
let view = view.clone();
let global_proxy_host_input = view.read(cx).global_proxy_host_input.clone();
let global_proxy_port_input = view.read(cx).global_proxy_port_input.clone();
let global_proxy_user_input = view.read(cx).global_proxy_user_input.clone();
let global_proxy_password_input = view.read(cx).global_proxy_password_input.clone();
move |_, window, cx| {
let proxy_type = view.read(cx).global_proxy_type.clone();
v_flex()
.w_full()
.gap_3()
.child(div().text_sm().font_weight(FontWeight::BOLD).child(t!("global_proxy_settings").to_string()))
.child(
h_flex()
.gap_2()
.child(
Button::new("global-proxy-type-socks5")
.small()
.label("SOCKS5")
.when(proxy_type == "socks5", |b| b.primary())
.on_click(window.listener_for(&view, |this, _, _, cx| {
this.global_proxy_type = "socks5".to_string();
cx.notify();
}))
)
.child(
Button::new("global-proxy-type-http")
.small()
.label("HTTP")
.when(proxy_type == "http", |b| b.primary())
.on_click(window.listener_for(&view, |this, _, _, cx| {
this.global_proxy_type = "http".to_string();
cx.notify();
}))
)
)
.child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_host").to_string())).child(Input::new(&global_proxy_host_input).w_full()))
.child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_port").to_string())).child(Input::new(&global_proxy_port_input).w_full()))
.child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_user").to_string())).child(Input::new(&global_proxy_user_input).w_full()))
.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(
Button::new("save-global-proxy")
.small()
.primary()
.label(t!("save_proxy").to_string())
.on_click(window.listener_for(&view, |this, _, _, cx| {
let host = this.global_proxy_host_input.read(cx).value().trim().to_string();
let port_str = this.global_proxy_port_input.read(cx).value();
let port = port_str.trim().parse::<u16>().ok();
let user = this.global_proxy_user_input.read(cx).value().trim().to_string();
let password = this.global_proxy_password_input.read(cx).value().to_string();
if host.is_empty() || port.is_none() {
return;
}
this.config.set_global_proxy_type(this.global_proxy_type.clone());
this.config.set_global_proxy_host(host);
this.config.set_global_proxy_port(port);
this.config.set_global_proxy_user(user);
this.config.set_global_proxy_password(password);
let _ = this.config.save();
cx.notify();
}))
)
}
}))
)
)
.page({
let mut page = SettingPage::new(t!("settings_key_bindings").to_string())
.icon(IconName::SquareTerminal)
@@ -1797,7 +2013,7 @@ impl Ashell {
)
.child(
div()
.text_size(rems(0.9))
.text_size(rems(0.9))
.text_color(cx.theme().muted_foreground)
.child(t!("about_feedback_hint")),
)
@@ -1818,101 +2034,4 @@ impl Ashell {
})
});
}
pub(crate) fn show_interactive_prompt_dialog(
&mut self,
tab_id: String,
prompt_type: crate::terminal::PromptType,
instruction: String,
prompts: Vec<crate::terminal::PromptInfo>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = cx.entity();
// Dynamically instantiate InputState for each prompt
let mut input_states = Vec::new();
for p in &prompts {
let is_masked = !p.echo;
let input_state = cx.new(|cx| {
let mut state = InputState::new(window, cx).placeholder(p.prompt.clone());
if is_masked {
state = state.masked(true);
}
state
});
input_states.push(input_state);
}
let tab_id_clone = tab_id.clone();
let input_states_clone = input_states.clone();
self.active_dialog = Some(crate::app::DialogKind::PromptRequest);
let tab_id_for_close = tab_id.clone();
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
let title = match prompt_type {
crate::terminal::PromptType::KeyboardInteractive => "Keyboard Interactive Authentication",
crate::terminal::PromptType::Passphrase => "Enter Private Key Passphrase",
};
let tab_id_for_ok = tab_id_clone.clone();
let input_states_for_ok = input_states_clone.clone();
let view_for_ok = view.clone();
let view_for_close = view.clone();
let tab_id_for_close = tab_id_for_close.clone();
let instruction_for_content = instruction.clone();
let input_states_for_content = input_states_clone.clone();
dialog
.title(title)
.w(px(500.))
.overlay_closable(false)
.on_close(move |_, _, cx| {
view_for_close.update(cx, |this, cx| {
this.active_dialog = None;
// Send Close command to abort connection if they close the dialog without OK
if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_close) {
tab.backend.send(crate::terminal::BackendCommand::Close);
}
cx.notify();
});
})
.on_ok(move |_, window, cx| {
view_for_ok.update(cx, |this, cx| {
this.active_dialog = None;
let mut responses = Vec::new();
for state in &input_states_for_ok {
responses.push(state.read(cx).text().to_string());
}
if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_ok) {
tab.backend.send(crate::terminal::BackendCommand::PromptResponse(responses));
}
cx.notify();
});
window.close_dialog(cx);
true
})
.content(move |content, _window, _cx| {
let mut container = v_flex().gap_3();
if !instruction_for_content.is_empty() {
container = container.child(
div()
.text_sm()
.text_color(gpui::rgba(0x808080ff))
.child(instruction_for_content.clone())
);
}
for input_state in &input_states_for_content {
container = container.child(
Input::new(input_state).w_full()
);
}
content.child(container)
})
});
}
}
+18 -2
View File
@@ -29,7 +29,9 @@ gpui::actions!(
SplitPaneRight,
SplitPaneUp,
SplitPaneDown,
ClosePane
ClosePane,
Copy,
Paste
]
);
@@ -123,6 +125,16 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
label_key: "settings_close_pane",
default_suffix: "w",
},
WorkspaceAction {
id: "Copy",
label_key: "settings_copy",
default_suffix: if cfg!(target_os = "macos") { "c" } else { "shift-c" },
},
WorkspaceAction {
id: "Paste",
label_key: "settings_paste",
default_suffix: if cfg!(target_os = "macos") { "v" } else { "shift-v" },
},
];
pub(crate) fn default_modifier() -> &'static str {
@@ -224,6 +236,8 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
unbind_action!("ClosePane", crate::ClosePane);
unbind_action!("Copy", crate::Copy);
unbind_action!("Paste", crate::Paste);
cx.bind_keys(bindings);
}
@@ -280,6 +294,8 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("SplitPaneUp", crate::SplitPaneUp);
bind_action!("SplitPaneDown", crate::SplitPaneDown);
bind_action!("ClosePane", crate::ClosePane);
bind_action!("Copy", crate::Copy);
bind_action!("Paste", crate::Paste);
cx.bind_keys(bindings);
}
@@ -289,7 +305,7 @@ impl KeybindingsPage {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch"],
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch", "Copy", "Paste"],
),
(
"settings_group_keybind_zoom",
+293 -78
View File
@@ -197,7 +197,6 @@ pub(crate) enum DialogKind {
SessionSelector,
Transfers,
NewSsh,
PromptRequest,
}
pub(crate) struct Ashell {
@@ -211,6 +210,16 @@ pub(crate) struct Ashell {
pub(crate) key_path_input: Entity<InputState>,
pub(crate) key_inline_input: Entity<InputState>,
pub(crate) passphrase_input: Entity<InputState>,
pub(crate) ssh_proxy_type: String,
pub(crate) proxy_host_input: Entity<InputState>,
pub(crate) proxy_port_input: Entity<InputState>,
pub(crate) proxy_user_input: Entity<InputState>,
pub(crate) proxy_password_input: Entity<InputState>,
pub(crate) global_proxy_type: String,
pub(crate) global_proxy_host_input: Entity<InputState>,
pub(crate) global_proxy_port_input: Entity<InputState>,
pub(crate) global_proxy_user_input: Entity<InputState>,
pub(crate) global_proxy_password_input: Entity<InputState>,
pub(crate) sync_endpoint_input: Entity<InputState>,
pub(crate) sync_username_input: Entity<InputState>,
pub(crate) sync_webdav_password_input: Entity<InputState>,
@@ -233,6 +242,7 @@ pub(crate) struct Ashell {
pub(crate) dark_theme_name: SharedString,
pub(crate) ui_font_size: f32,
pub(crate) terminal_font_size: f32,
pub(crate) terminal_zoom_accumulator: f32,
pub(crate) ui_font_family: SharedString,
pub(crate) terminal_font_family: SharedString,
pub(crate) tabs: Vec<TerminalTab>,
@@ -275,6 +285,7 @@ pub(crate) struct Ashell {
pub(crate) status: SharedString,
pub(crate) config: ConfigStore,
pub(crate) active_title_bar_style: crate::session::config::TitleBarStyle,
pub(crate) cursor_style: crate::session::config::CursorStyle,
pub(crate) system_sampler: SystemSampler,
pub(crate) recording_action: Option<String>,
pub(crate) active_dialog: Option<DialogKind>,
@@ -304,6 +315,8 @@ pub(crate) struct Ashell {
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
pub(crate) events_tx: mpsc::Sender<BackendEvent>,
pub(crate) last_window_size: Option<gpui::Size<Pixels>>,
pub(crate) last_sidebar_width: Option<Pixels>,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
}
@@ -374,6 +387,10 @@ impl Ashell {
.placeholder("SSH private key passphrase (optional)")
.masked(true)
});
let proxy_host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
let proxy_port_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
let proxy_user_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
let proxy_password_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_password").to_string()).masked(true));
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
let sftp_new_folder_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
@@ -384,6 +401,27 @@ impl Ashell {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
});
let global_proxy_host_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_host").to_string())
.default_value(config.global_proxy_host())
});
let global_proxy_port_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_port").to_string())
.default_value(config.global_proxy_port().map(|p| p.to_string()).unwrap_or_default())
});
let global_proxy_user_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_user").to_string())
.default_value(config.global_proxy_user())
});
let global_proxy_password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("proxy_password").to_string())
.masked(true)
.default_value(config.global_proxy_password())
});
let sync_endpoint_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("https://dav.example.com/ashell/")
@@ -447,6 +485,10 @@ impl Ashell {
cx.subscribe_in(&key_path_input, window, Self::on_input_event),
cx.subscribe_in(&key_inline_input, window, Self::on_input_event),
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_host_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_port_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_user_input, window, Self::on_input_event),
cx.subscribe_in(&proxy_password_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
cx.subscribe_in(&search_input, window, Self::on_input_event),
@@ -506,6 +548,10 @@ impl Ashell {
gpui_component::set_locale(&active_locale);
let ui_font_family: SharedString = config.ui_font_family().into();
let terminal_font_family: SharedString = config.terminal_font_family().into();
let last_sidebar_width = Some(px(config
.workspace_panels()
.and_then(|s| s.first().copied())
.unwrap_or(constants::SIDEBAR_WIDTH)));
let mut this = Self {
focus_handle: cx.focus_handle(),
selector_focus_handle: cx.focus_handle(),
@@ -517,6 +563,16 @@ impl Ashell {
key_path_input,
key_inline_input,
passphrase_input,
ssh_proxy_type: "none".to_string(),
proxy_host_input,
proxy_port_input,
proxy_user_input,
proxy_password_input,
global_proxy_type: config.global_proxy_type().to_string(),
global_proxy_host_input,
global_proxy_port_input,
global_proxy_user_input,
global_proxy_password_input,
sync_endpoint_input,
sync_username_input,
sync_webdav_password_input,
@@ -539,6 +595,8 @@ impl Ashell {
dark_theme_name,
ui_font_size: config.ui_font_size(),
terminal_font_size: config.terminal_font_size(),
terminal_zoom_accumulator: 0.0,
cursor_style: config.cursor_style(),
ui_font_family,
terminal_font_family,
tabs: Vec::new(),
@@ -621,12 +679,14 @@ impl Ashell {
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
events_tx,
last_window_size: None,
last_sidebar_width,
_subscriptions,
};
this.apply_theme_preferences(window, cx);
// this.open_local(cx);
this.start_event_pump(window, cx);
this.start_event_pump(cx);
this
}
@@ -688,23 +748,32 @@ impl Ashell {
cx.notify();
}
pub(crate) fn start_event_pump(&self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, mut cx| {
pub(crate) fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| {
let mut idle_frames = 0u32;
let mut last_blink_time = std::time::Instant::now();
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
let mut changed = false;
let mut system_sampled = false;
let update_res = gpui::AsyncWindowContext::update(&mut cx, |window, cx| {
let _ = this.update(cx, |this, cx| {
changed = this.drain_backend_events(window, cx);
system_sampled = this.sample_system_if_due();
if this
.update(cx, |this, cx| {
let changed = this.drain_backend_events();
let system_sampled = this.sample_system_if_due();
this.sync_theme_if_due(cx);
if changed || system_sampled {
let is_blinking = matches!(
this.cursor_style,
crate::session::config::CursorStyle::Blink
| crate::session::config::CursorStyle::BeamBlink
);
let now = std::time::Instant::now();
let blink_due = is_blinking && now.duration_since(last_blink_time) >= std::time::Duration::from_millis(600);
if changed || system_sampled || blink_due {
cx.notify();
idle_frames = 0;
if blink_due {
last_blink_time = now;
}
} else {
idle_frames += 1;
if idle_frames >= 60 {
@@ -712,9 +781,9 @@ impl Ashell {
idle_frames = 0;
}
}
});
});
if update_res.is_err() {
})
.is_err()
{
break;
}
}
@@ -722,7 +791,7 @@ impl Ashell {
.detach();
}
pub(crate) fn drain_backend_events(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
pub(crate) fn drain_backend_events(&mut self) -> bool {
let mut changed = false;
let mut transfers_changed = false;
while let Ok(event) = self.events_rx.try_recv() {
@@ -730,11 +799,13 @@ impl Ashell {
match event {
BackendEvent::Output { tab_id, bytes } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
tab.feed(&bytes);
}
}
BackendEvent::Status { tab_id, text } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
tab.status = text.clone();
}
if let Some(progress) = self.connection_progress.as_mut() {
@@ -742,14 +813,16 @@ impl Ashell {
progress.lines.push(text.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
.set_offset(gpui::point(px(0.), px(-99999.0)));
}
}
self.status = text.into();
}
BackendEvent::Connected { tab_id } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.backend_initialized = true;
tab.connected = true;
tab.disconnected_reason = None;
}
self.sync_system_tab_to_active_group();
self.request_active_system_snapshot();
@@ -761,21 +834,6 @@ impl Ashell {
self.connection_progress = None;
}
}
BackendEvent::PromptRequest {
tab_id,
prompt_type,
instruction,
prompts,
} => {
self.show_interactive_prompt_dialog(
tab_id,
prompt_type,
instruction,
prompts,
window,
cx,
);
}
BackendEvent::SftpEntries {
tab_id,
path,
@@ -835,67 +893,45 @@ impl Ashell {
}
BackendEvent::Closed { tab_id, reason } => {
self.remote_sample_in_flight = false;
let mut tab_title = None;
let mut session_label = None;
let is_stale = self
.tabs
.iter()
.find(|t| t.id == tab_id)
.is_some_and(|tab| {
// After retry_disconnected_tab, the old backend's threads
// may still send Closed events. Skip those — they arrive
// before the new backend sends its first Output/Connected.
// Once backend_initialized is set, any Closed is from the
// current backend and should be processed.
tab.backend_generation > 0 && !tab.backend_initialized
});
if is_stale {
continue;
}
let is_graceful_exit =
reason == "local shell closed" || reason == "ssh session closed";
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
self.status = reason.into();
continue;
}
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.connected = false;
tab.status = reason.clone();
tab_title = Some(tab.title.clone());
session_label = tab.session.as_ref().map(|session| {
format!("{}@{}:{}", session.user, session.host, session.port)
});
tab.disconnected_reason = Some(reason.clone());
}
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
}
let is_graceful_exit =
reason == "local shell closed" || reason == "ssh session closed";
// Auto-close the pane on graceful exit (e.g. user typed exit)
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
self.status = reason.into();
self.remote_sample_in_flight = false;
return changed;
}
let mut needs_new_progress = false;
if let Some(progress) = self.connection_progress.as_mut() {
if progress.tab_id == tab_id {
progress.lines.push(reason.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
let _ = session_label;
let _ = tab_title;
.set_offset(gpui::point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else if !progress.failed {
// We were showing connecting progress, but another tab dropped!
// Switch to failed state so the user can see it and retry.
progress.tab_id = tab_id.clone();
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else {
// Already showing a failure dialog, just append the new failure
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
}
} else if let Some(_) = session_label {
needs_new_progress = true;
}
if needs_new_progress && !is_graceful_exit {
self.connection_progress = Some(ConnectionProgress {
tab_id: tab_id.clone(),
title: t!("connection_failed").into(),
lines: vec![reason.clone().into()],
failed: true,
});
}
self.status = reason.into();
}
@@ -1039,7 +1075,9 @@ impl Ashell {
return;
}
self.remote_sample_in_flight = true;
backend.send(crate::terminal::BackendCommand::SampleMetrics);
if let Ok(backend) = backend.lock() {
backend.send(crate::terminal::BackendCommand::SampleMetrics);
}
}
pub(crate) fn terminal_ime_bounds_for_range(
@@ -1066,4 +1104,181 @@ impl Ashell {
self.config.set_transfers(self.transfers.clone());
cx.notify();
}
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
return;
};
self.connection_progress = None;
let mut retry_tabs = Vec::new();
for (ix, tab) in self.tabs.iter().enumerate() {
if !tab.connected && tab.session.is_some() && tab.id == progress.tab_id {
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
}
}
if retry_tabs.is_empty() {
cx.notify();
return;
}
for (ix, tab_id, session) in retry_tabs {
// Close old backend
self.tabs[ix].send_backend(crate::terminal::BackendCommand::Close);
// Spawn new backend
let backend = crate::backend::ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.clone(),
session.clone(),
self.tabs[ix].cols,
self.tabs[ix].rows,
self.events_tx.clone(),
);
// Replace tab state
self.tabs[ix].set_backend(backend);
self.tabs[ix].connected = false;
self.tabs[ix].status = "connecting".into();
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].backend_initialized = false;
// Restart SFTP for the group containing this tab
if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&tab_id))
{
let group_id = group.id.clone();
let group_session = self
.tabs
.iter()
.find(|t| group.pane_root.contains(&t.id) && t.session.is_some())
.and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
}
}
}
}
self.connection_progress = Some(ConnectionProgress {
tab_id: progress.tab_id.clone(),
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tabs retrying".into();
cx.notify();
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
if let Some(progress) = &self.connection_progress {
let tab_id = progress.tab_id.clone();
self.connection_progress = None;
self.handle_tab_close(tab_id);
}
cx.notify();
}
pub(crate) fn save_layout_state(&self, window: &mut gpui::Window, cx: &gpui::App) {
if self.is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
return;
}
let current_bounds = window.window_bounds();
let bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => b,
gpui::WindowBounds::Maximized(b) => b,
gpui::WindowBounds::Windowed(b) => b,
};
let size = bounds.size;
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
tracing::info!("[ui] saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
let mut restore_bounds = (b.origin.x.into(), b.origin.y.into(), b.size.width.into(), b.size.height.into());
if let Some(existing_bounds) = config.window_bounds() {
match existing_bounds {
crate::session::config::SavedWindowBounds::Windowed { x, y, width, height } => {
restore_bounds = (*x, *y, *width, *height);
}
crate::session::config::SavedWindowBounds::Maximized { x, y, width, height } => {
restore_bounds = (*x, *y, *width, *height);
}
_ => {}
}
}
crate::session::config::SavedWindowBounds::Maximized {
x: restore_bounds.0,
y: restore_bounds.1,
width: restore_bounds.2,
height: restore_bounds.3,
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = self.workspace_panels
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = self.body_panels
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if self.sftp_panel_minimized {
if let Some(prev) = self.prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(self.sidebar_collapsed);
config.set_sftp_panel_minimized(self.sftp_panel_minimized);
let _ = config.save();
} else {
tracing::warn!(
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
size
);
}
}
}
+54 -71
View File
@@ -162,6 +162,12 @@ pub(crate) fn sync_macos_launch_environment() {
| "HOMEBREW_PREFIX"
| "HOMEBREW_CELLAR"
| "HOMEBREW_REPOSITORY"
| "HTTP_PROXY"
| "HTTPS_PROXY"
| "ALL_PROXY"
| "http_proxy"
| "https_proxy"
| "all_proxy"
) || key.starts_with("LC_");
if should_import {
@@ -172,12 +178,56 @@ pub(crate) fn sync_macos_launch_environment() {
}
}
fn read_proxy_from_env() -> Option<(String, String, Option<u16>, String, String)> {
let vars = ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
for var in vars {
if let Ok(val) = std::env::var(var) {
if val.is_empty() {
continue;
}
if let Ok(url) = reqwest::Url::parse(&val) {
let scheme = url.scheme();
let proxy_type = match scheme {
"socks5" | "socks5h" => "socks5".to_string(),
"http" | "https" => "http".to_string(),
_ => "socks5".to_string(),
};
let host = url.host_str().unwrap_or("").to_string();
let port = url.port();
let user = url.username().to_string();
let password = url.password().unwrap_or("").to_string();
return Some((proxy_type, host, port, user, password));
}
}
}
None
}
#[cfg(not(target_os = "macos"))]
pub(crate) fn sync_macos_launch_environment() {}
pub(crate) fn open_main_window(cx: &mut App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let _ = crate::session::config::ENV_PROXY.get_or_init(|| {
read_proxy_from_env().map(|(proxy_type, host, port, user, password)| {
tracing::info!(
"[proxy] Loaded proxy configuration from environment: type={}, host={}, port={:?}, user={}",
proxy_type,
host,
port,
user
);
crate::session::config::EnvProxy {
proxy_type,
host,
port,
user,
pass: password,
}
})
});
let mut window_options = WindowOptions::default();
if config.title_bar_style() == crate::session::config::TitleBarStyle::Integrated {
@@ -253,81 +303,14 @@ pub(crate) fn open_main_window(cx: &mut App) {
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
let handle = window.window_handle();
if !cx.windows().contains(&handle) {
tracing::warn!("[ui] window not found in app during close, skipping save layout state.");
return true;
}
let current_bounds = window.window_bounds();
let bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => b,
gpui::WindowBounds::Maximized(b) => b,
gpui::WindowBounds::Windowed(b) => b,
};
let size = bounds.size;
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = workspace_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
let mut body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if view_clone.read(cx).sftp_panel_minimized {
if let Some(prev) = view_clone.read(cx).prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(view_clone.read(cx).sidebar_collapsed);
config.set_sftp_panel_minimized(view_clone.read(cx).sftp_panel_minimized);
let _ = config.save();
} else {
tracing::warn!(
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
size
);
}
view_clone.read(cx).save_layout_state(window, cx);
true
});
+18 -9
View File
@@ -12,16 +12,25 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/phygerr.json"),
];
use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
let has_system_maple = cx.text_system().all_font_names().contains(&"Maple Mono NF CN".to_string());
if has_system_maple {
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
} else {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
}
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
Ok(())
}
+191 -30
View File
@@ -2,7 +2,7 @@ use gpui::{
Context, ElementId, 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, rems, uniform_list,
prelude::FluentBuilder as _, px, relative, rems, uniform_list,
};
use gpui_component::{
ActiveTheme, Disableable as _, ElementExt, Icon, IconName, InteractiveElementExt as _, Root,
@@ -1980,52 +1980,109 @@ impl Ashell {
fn render_window_controls(
&self,
window: &mut Window,
_cx: &mut Context<Self>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let is_macos = cfg!(target_os = "macos");
let is_fullscreen = window.is_fullscreen();
let is_active = cx.active_window() == Some(window.window_handle());
h_flex()
.group("window-controls")
.flex_none()
.items_center()
.px_3()
.gap_2()
.when(!is_macos || is_fullscreen, |this| {
this.child(
div()
h_flex()
.id("window-close")
.size(px(12.))
.rounded_full()
.bg(hsla(0.0, 0.8, 0.4, 1.0)) // Red
.on_click(|_, window, _| window.remove_window())
.hover(|s| s.bg(hsla(0.0, 0.8, 0.5, 1.0)))
.active(|s| s.bg(hsla(0.0, 0.8, 0.3, 1.0))),
.bg(if is_active { hsla(3.0 / 360.0, 1.0, 0.67, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Red or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(3.0 / 360.0, 1.0, 0.67, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Close))
.on_click(cx.listener(|this, _, window, cx| {
this.save_layout_state(window, cx);
window.remove_window();
}))
.hover(|s| s.bg(hsla(3.0 / 360.0, 1.0, 0.55, 1.0)))
.active(|s| s.bg(hsla(3.0 / 360.0, 1.0, 0.45, 1.0)))
.items_center()
.justify_center()
.child(
h_flex()
.items_center()
.justify_center()
.text_size(px(7.))
.font_weight(FontWeight::BOLD)
.line_height(relative(1.0))
.text_color(hsla(3.0 / 360.0, 1.0, 0.15, 0.7))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("✕")
),
)
.child(
div()
h_flex()
.id("window-minimize")
.size(px(12.))
.rounded_full()
.bg(hsla(0.12, 0.8, 0.4, 1.0)) // Yellow
.bg(if is_active { hsla(39.0 / 360.0, 1.0, 0.59, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Yellow or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(39.0 / 360.0, 1.0, 0.59, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Min))
.on_click(|_, window, _| window.minimize_window())
.hover(|s| s.bg(hsla(0.12, 0.8, 0.5, 1.0)))
.active(|s| s.bg(hsla(0.12, 0.8, 0.3, 1.0))),
.hover(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.49, 1.0)))
.active(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.39, 1.0)))
.items_center()
.justify_center()
.child(
h_flex()
.items_center()
.justify_center()
.text_size(px(7.))
.font_weight(FontWeight::BOLD)
.line_height(relative(1.0))
.text_color(hsla(39.0 / 360.0, 1.0, 0.15, 0.8))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("−")
),
)
.child(
div()
h_flex()
.id("window-maximize")
.size(px(12.))
.rounded_full()
.bg(hsla(0.33, 0.8, 0.4, 1.0)) // Green
.bg(if is_active { hsla(127.0 / 360.0, 0.68, 0.47, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Green or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(127.0 / 360.0, 0.68, 0.47, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Max))
.on_click(|_, window, _| {
if window.is_fullscreen() {
window.toggle_fullscreen();
} else {
#[cfg(target_os = "macos")]
window.titlebar_double_click();
#[cfg(not(target_os = "macos"))]
window.zoom_window();
}
})
.hover(|s| s.bg(hsla(0.33, 0.8, 0.5, 1.0)))
.active(|s| s.bg(hsla(0.33, 0.8, 0.3, 1.0))),
.hover(|s| s.bg(hsla(127.0 / 360.0, 0.68, 0.37, 1.0)))
.active(|s| s.bg(hsla(127.0 / 360.0, 0.68, 0.27, 1.0)))
.items_center()
.justify_center()
.child(
h_flex()
.items_center()
.justify_center()
.text_size(px(7.))
.font_weight(FontWeight::BOLD)
.line_height(relative(1.0))
.text_color(hsla(127.0 / 360.0, 1.0, 0.15, 0.8))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("+")
),
)
})
.when(is_macos, |this| {
@@ -2213,8 +2270,11 @@ impl Ashell {
div()
.size_full()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_panel_bounds = Some(bounds);
let _ = view.update(cx, |this, cx| {
if this.terminal_panel_bounds != Some(bounds) {
this.terminal_panel_bounds = Some(bounds);
cx.notify();
}
});
})
.overflow_hidden()
@@ -2263,8 +2323,6 @@ impl Ashell {
let Some(snapshot) = snapshot else {
return div().into_any_element();
};
let view = cx.entity();
let tab_id_clone = tab_id.clone();
let tab_id_clone2 = tab_id.clone();
let focus_handle = this.focus_handle.clone();
let marked_text = if is_focused {
@@ -2279,11 +2337,6 @@ impl Ashell {
let mut el = div()
.size_full()
.overflow_hidden()
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
this.terminal_bounds.insert(tab_id_clone.clone(), bounds);
});
})
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
@@ -2300,6 +2353,7 @@ impl Ashell {
font_size,
line_height,
cell_width,
tab_id.to_string(),
this.search_highlight_map(
tab_id,
cx.theme().danger.opacity(0.35),
@@ -2308,6 +2362,56 @@ 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.
// Uses absolute positioning so the terminal element itself is unchanged,
// keeping panel size stable in multi-panel layouts.
let disconnected_reason = this
.tabs
.iter()
.find(|t| t.id == *tab_id)
.and_then(|tab| tab.disconnected_reason.clone());
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(
h_flex()
.w_full()
.items_center()
.gap_2()
.px_3()
.py_1()
.bg(cx.theme().danger.opacity(0.15))
.child(
div()
.text_size(rems(0.85))
.text_color(cx.theme().danger)
.child(t!("session_disconnected", "reason" = reason).to_string()),
)
.child(
div()
.text_size(rems(0.85))
.text_color(cx.theme().muted_foreground)
.child(format!("— {}", t!("press_enter_to_reconnect"))),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.retry_disconnected_tab(&tab_id_for_reconnect, cx);
}),
),
),
);
}
let indicator_color = this
.tabs
.iter()
@@ -2326,10 +2430,10 @@ impl Ashell {
el = el.opacity(0.85);
}
let mut wrapper = div().size_full();
if has_multiple_panes {
if is_focused {
el = div()
.size_full()
wrapper = wrapper
.relative()
.child(
div()
@@ -2370,11 +2474,13 @@ impl Ashell {
.p(px(4.))
.child(el);
} else {
el = div().size_full().p(px(4.)).child(el);
wrapper = wrapper.p(px(4.)).child(el);
}
} else {
wrapper = wrapper.child(el);
}
el.into_any_element()
wrapper.into_any_element()
}
PaneLayout::Horizontal(children, ratio) => {
v_flex()
@@ -2489,7 +2595,7 @@ impl Render for Ashell {
self.active_tab = self.tabs.first().map(|tab| tab.id.clone());
}
self.sync_sftp_path_input(window, cx);
self.sync_terminal_size(window, cx);
if self.show_transfers_dialog {
self.show_transfers_dialog = false;
self.show_transfers_dialog(window, cx);
@@ -2666,6 +2772,33 @@ impl Render for Ashell {
this.close_tab(active_id, cx);
}
}))
.on_action(cx.listener(|this, _: &crate::Copy, window, cx| {
if window.focused(cx) == Some(this.focus_handle.clone()) {
if let Some(text) = this.active_terminal_selection_text() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
if let Some(active_id) = &this.active_tab {
if let Some(tab) = this.tabs.iter_mut().find(|tab| &tab.id == active_id) {
tab.clear_selection();
}
}
window.prevent_default();
cx.stop_propagation();
}
} else {
cx.propagate();
}
}))
.on_action(cx.listener(|this, _: &crate::Paste, window, cx| {
if window.focused(cx) == Some(this.focus_handle.clone()) {
if let Some(clipboard) = cx.read_from_clipboard() {
if let Some(text) = clipboard.text() {
this.paste_into_terminal(&text, window, cx);
}
}
} else {
cx.propagate();
}
}))
.when(self.active_title_bar_style == crate::session::config::TitleBarStyle::Integrated, |this| {
this.child(
div()
@@ -2676,7 +2809,10 @@ impl Render for Ashell {
.w_full()
.bg(cx.theme().tab_bar)
.on_double_click(|_, window, _| {
#[cfg(target_os = "macos")]
window.titlebar_double_click();
#[cfg(not(target_os = "macos"))]
window.zoom_window();
})
.child(self.render_window_controls(window, cx))
.child(
@@ -2782,7 +2918,7 @@ impl Render for Ashell {
.left_0()
.right_0()
.bottom_0()
.bg(Hsla {
.bg(gpui::Hsla {
h: 0.0,
s: 0.0,
l: 0.0,
@@ -2883,5 +3019,30 @@ impl Render for Ashell {
),
)
})
.on_prepaint({
let view = cx.entity().clone();
move |_, window, cx| {
view.update(cx, |this, cx| {
let current_win_size = window.viewport_size();
let size_changed = this.last_window_size.map_or(true, |prev| prev != current_win_size);
this.last_window_size = Some(current_win_size);
let current_sizes = this.workspace_panels.read(cx).sizes().clone();
if let Some(current_first_size) = current_sizes.first().copied() {
if size_changed {
if let Some(target_width) = this.last_sidebar_width {
if current_first_size != target_width {
this.workspace_panels.update(cx, |state, cx| {
state.resize_panel(0, target_width, window, cx);
});
}
}
} else {
this.last_sidebar_width = Some(current_first_size);
}
}
});
}
})
}
}
-1
View File
@@ -117,7 +117,6 @@ pub fn spawn_local_terminal(
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
BackendCommand::PromptResponse(_) => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
+14 -171
View File
@@ -16,23 +16,8 @@ use tokio::sync::mpsc;
use crate::{
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx, PromptType, PromptInfo},
terminal::{BackendCommand, BackendEvent, BackendTx},
};
use std::sync::OnceLock;
use std::collections::HashMap;
use tokio::sync::Mutex;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct CachedCreds {
pub password: Option<String>,
pub passphrase: Option<String>,
pub kb_responses: Option<Vec<String>>,
}
pub static CREDENTIALS_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedCreds>>> = OnceLock::new();
pub static PROMPT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
pub fn spawn_ssh_terminal(
runtime: &tokio::runtime::Handle,
@@ -110,7 +95,7 @@ async fn run_ssh(
});
let handle = Arc::new(tokio::sync::Mutex::new(
connect_and_authenticate(&tab_id, &session, &events, &mut commands).await?,
connect_and_authenticate(&tab_id, &session, &events).await?,
));
let mut channel = handle
@@ -171,9 +156,6 @@ async fn run_ssh(
}
});
}
Some(BackendCommand::PromptResponse(_)) => {
tracing::warn!("[ssh] received unexpected prompt response after authentication");
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
@@ -231,32 +213,15 @@ async fn run_ssh(
Ok(())
}
async fn load_session_private_key_with_cache(session: &Session) -> Result<PrivateKey> {
let cached_passphrase = {
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.passphrase.clone())
};
if let Some(p) = cached_passphrase {
let mut temp_session = session.clone();
temp_session.passphrase = p;
if let Ok(key) = load_session_private_key(&temp_session) {
return Ok(key);
}
}
load_session_private_key(session)
}
async fn connect_and_authenticate(
tab_id: &str,
session: &Session,
events: &std::sync::mpsc::Sender<BackendEvent>,
commands: &mut mpsc::UnboundedReceiver<BackendCommand>,
) -> Result<russh::client::Handle<ClientHandler>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
keepalive_interval: Some(std::time::Duration::from_secs(10)),
keepalive_max: 3,
keepalive_interval: Some(std::time::Duration::from_secs(3)),
keepalive_max: 2,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
@@ -265,11 +230,18 @@ async fn connect_and_authenticate(
addr,
session.user
);
let status_text = if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
format!("connecting to {addr} via {} proxy {}:{}", ptype.to_uppercase(), phost, pport_val)
} else {
format!("opening tcp connection to {addr}")
};
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
text: status_text,
});
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
let stream = crate::session::config::connect_proxy(session).await?;
let mut handle = client::connect_stream(config, stream, ClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
@@ -306,55 +278,7 @@ async fn connect_and_authenticate(
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
});
let mut keypair = load_session_private_key_with_cache(session).await;
if let Err(e) = &keypair {
let err_str = e.to_string();
if err_str.contains("encrypted") || err_str.contains("passphrase") || err_str.contains("decrypt") {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
let _ = events.send(BackendEvent::PromptRequest {
tab_id: tab_id.to_string(),
prompt_type: PromptType::Passphrase,
instruction: format!("Enter passphrase for private key (source: {})", source),
prompts: vec![PromptInfo {
prompt: "Passphrase".to_string(),
echo: false,
}],
});
let mut passphrase_res = None;
while let Some(cmd) = commands.recv().await {
match cmd {
BackendCommand::PromptResponse(responses) => {
if let Some(p) = responses.first().cloned() {
passphrase_res = Some(p);
}
break;
}
BackendCommand::Close => {
return Err(anyhow!("Authentication cancelled by user"));
}
_ => {}
}
}
if let Some(p) = passphrase_res {
{
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = cache_lock.lock().unwrap();
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
password: None,
passphrase: None,
kb_responses: None,
}).passphrase = Some(p.clone());
}
let mut temp_session = session.clone();
temp_session.passphrase = p;
keypair = load_session_private_key(&temp_session);
} else {
return Err(anyhow!("Passphrase prompt cancelled"));
}
}
}
let keypair = keypair.context("failed to load private key")?;
let keypair = load_session_private_key(session)?;
let algorithm = format!("{:?}", keypair.algorithm());
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
@@ -390,83 +314,6 @@ async fn connect_and_authenticate(
}
success
}
AuthMethod::KeyboardInteractive => {
let cached = {
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).cloned()
};
let mut response = if cached.as_ref().and_then(|c| c.kb_responses.as_ref()).is_some() {
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
} else {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
};
loop {
match response {
russh::client::KeyboardInteractiveAuthResponse::Success => {
break true;
}
russh::client::KeyboardInteractiveAuthResponse::Failure => {
break false;
}
russh::client::KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
let mut responses = Vec::new();
let mut cache_hit = false;
if let Some(c) = cached.as_ref().and_then(|c| c.kb_responses.as_ref()) {
if c.len() == prompts.len() {
responses = c.clone();
cache_hit = true;
}
}
if !cache_hit {
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
let _guard = prompt_lock.lock().await;
let mut prompt_infos = Vec::new();
for p in &prompts {
prompt_infos.push(PromptInfo {
prompt: p.prompt.clone(),
echo: p.echo,
});
}
let _ = events.send(BackendEvent::PromptRequest {
tab_id: tab_id.to_string(),
prompt_type: PromptType::KeyboardInteractive,
instruction: format!("{}\n{}", name, instructions),
prompts: prompt_infos,
});
let mut prompt_res = None;
while let Some(cmd) = commands.recv().await {
match cmd {
BackendCommand::PromptResponse(res) => {
prompt_res = Some(res);
break;
}
BackendCommand::Close => {
return Err(anyhow!("Authentication cancelled"));
}
_ => {}
}
}
if let Some(res) = prompt_res {
responses = res;
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut cache = cache_lock.lock().unwrap();
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
password: None,
passphrase: None,
kb_responses: None,
}).kb_responses = Some(responses.clone());
} else {
return Err(anyhow!("Keyboard-interactive cancelled"));
}
}
response = handle.authenticate_keyboard_interactive_respond(responses).await?;
}
}
}
}
};
if !authed {
@@ -488,10 +335,6 @@ async fn connect_and_authenticate(
session.port,
key_source_label(session)
),
AuthMethod::KeyboardInteractive => format!(
"authentication failed: server rejected keyboard-interactive authentication for {}@{}:{}",
session.user, session.host, session.port
),
}
));
}
+2 -2
View File
@@ -18,11 +18,11 @@ gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar, Copy, Paste,
};
pub(crate) use app::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
fn main() {
+277 -23
View File
@@ -6,11 +6,10 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
Password,
Key,
KeyboardInteractive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -31,6 +30,16 @@ pub struct Session {
pub passphrase: String,
#[serde(default)]
pub last_used: Option<String>,
#[serde(default = "default_global_proxy_type")]
pub proxy_type: String, // "none", "socks5", "http"
#[serde(default)]
pub proxy_host: String,
#[serde(default)]
pub proxy_port: Option<u16>,
#[serde(default)]
pub proxy_user: String,
#[serde(default)]
pub proxy_password: String,
}
impl Session {
@@ -48,6 +57,11 @@ impl Session {
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
proxy_type: "none".to_string(),
proxy_host: String::new(),
proxy_port: None,
proxy_user: String::new(),
proxy_password: String::new(),
}
}
@@ -72,27 +86,11 @@ impl Session {
private_key_inline,
passphrase,
last_used: None,
}
}
pub fn keyboard_interactive(
host: String,
port: u16,
user: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
id: Uuid::new_v4().to_string(),
name,
host,
port,
user,
auth: AuthMethod::KeyboardInteractive,
password: String::new(),
private_key_path: String::new(),
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
proxy_type: "none".to_string(),
proxy_host: String::new(),
proxy_port: None,
proxy_user: String::new(),
proxy_password: String::new(),
}
}
}
@@ -128,6 +126,16 @@ pub enum TitleBarStyle {
Integrated,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum CursorStyle {
#[default]
Default,
Blink,
Beam,
BeamBlink,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default = "default_follow_system_theme")]
@@ -155,6 +163,8 @@ pub struct ConfigFile {
#[serde(default)]
pub title_bar_style: TitleBarStyle,
#[serde(default)]
pub cursor_style: CursorStyle,
#[serde(default)]
pub sessions: Vec<Session>,
#[serde(default)]
pub window_bounds: Option<SavedWindowBounds>,
@@ -194,6 +204,28 @@ pub struct ConfigFile {
pub sync_s3_bucket: String,
#[serde(default = "default_s3_object_key")]
pub sync_s3_object_key: String,
#[serde(default)]
pub use_proxy: bool,
#[serde(default = "default_read_env_proxy")]
pub read_env_proxy: bool,
#[serde(default = "default_global_proxy_type")]
pub global_proxy_type: String,
#[serde(default)]
pub global_proxy_host: String,
#[serde(default)]
pub global_proxy_port: Option<u16>,
#[serde(default)]
pub global_proxy_user: String,
#[serde(default)]
pub global_proxy_password: String,
}
fn default_read_env_proxy() -> bool {
true
}
fn default_global_proxy_type() -> String {
"socks5".to_string()
}
fn default_monitoring_position() -> String {
@@ -249,6 +281,7 @@ impl Default for ConfigFile {
ui_font_family: default_ui_font_family(),
terminal_font_family: default_terminal_font_family(),
title_bar_style: TitleBarStyle::default(),
cursor_style: CursorStyle::default(),
sessions: Vec::new(),
window_bounds: None,
workspace_panels: None,
@@ -269,6 +302,13 @@ impl Default for ConfigFile {
sync_s3_region: default_s3_region(),
sync_s3_bucket: String::new(),
sync_s3_object_key: default_s3_object_key(),
use_proxy: false,
read_env_proxy: true,
global_proxy_type: default_global_proxy_type(),
global_proxy_host: String::new(),
global_proxy_port: None,
global_proxy_user: String::new(),
global_proxy_password: String::new(),
}
}
}
@@ -603,6 +643,57 @@ impl ConfigStore {
self.cache.title_bar_style = style;
}
pub fn cursor_style(&self) -> CursorStyle {
self.cache.cursor_style
}
pub fn set_cursor_style(&mut self, style: CursorStyle) {
self.cache.cursor_style = style;
}
pub fn use_proxy(&self) -> bool {
self.cache.use_proxy
}
pub fn set_use_proxy(&mut self, val: bool) {
self.cache.use_proxy = val;
}
pub fn read_env_proxy(&self) -> bool {
self.cache.read_env_proxy
}
pub fn set_read_env_proxy(&mut self, val: bool) {
self.cache.read_env_proxy = val;
}
pub fn global_proxy_type(&self) -> &str {
&self.cache.global_proxy_type
}
pub fn set_global_proxy_type(&mut self, val: String) {
self.cache.global_proxy_type = val;
}
pub fn global_proxy_host(&self) -> &str {
&self.cache.global_proxy_host
}
pub fn set_global_proxy_host(&mut self, val: String) {
self.cache.global_proxy_host = val;
}
pub fn global_proxy_port(&self) -> Option<u16> {
self.cache.global_proxy_port
}
pub fn set_global_proxy_port(&mut self, val: Option<u16>) {
self.cache.global_proxy_port = val;
}
pub fn global_proxy_user(&self) -> &str {
&self.cache.global_proxy_user
}
pub fn set_global_proxy_user(&mut self, val: String) {
self.cache.global_proxy_user = val;
}
pub fn global_proxy_password(&self) -> &str {
&self.cache.global_proxy_password
}
pub fn set_global_proxy_password(&mut self, val: String) {
self.cache.global_proxy_password = val;
}
pub fn show_hidden_files(&self) -> bool {
self.cache.show_hidden_files
}
@@ -663,3 +754,166 @@ impl ConfigStore {
Ok(())
}
}
pub trait ProxyStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static {}
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static> ProxyStream for T {}
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct EnvProxy {
pub proxy_type: String,
pub host: String,
pub port: Option<u16>,
pub user: String,
pub pass: String,
}
pub static ENV_PROXY: OnceLock<Option<EnvProxy>> = OnceLock::new();
pub async fn connect_proxy(session: &Session) -> Result<Box<dyn ProxyStream>> {
let target_host = &session.host;
let target_port = session.port;
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = {
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
(
session.proxy_type.clone(),
session.proxy_host.clone(),
session.proxy_port,
session.proxy_user.clone(),
session.proxy_password.clone(),
)
} else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() {
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
(
env_p.proxy_type.clone(),
env_p.host.clone(),
env_p.port,
env_p.user.clone(),
env_p.pass.clone(),
)
} else if config.cache.use_proxy {
(
config.cache.global_proxy_type.clone(),
config.cache.global_proxy_host.clone(),
config.cache.global_proxy_port,
config.cache.global_proxy_user.clone(),
config.cache.global_proxy_password.clone(),
)
} else {
("none".to_string(), String::new(), None, String::new(), String::new())
}
};
if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
return Ok(Box::new(stream));
}
match proxy_type.as_str() {
"socks5" | "socks5h" => {
let proxy_port = proxy_port.unwrap_or(1080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
if !proxy_user.is_empty() {
let stream = tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
&proxy_user,
&proxy_password,
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream))
} else {
let stream = tokio_socks::tcp::Socks5Stream::connect(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream))
}
}
"http" => {
let proxy_port = proxy_port.unwrap_or(8080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
use tokio::io::AsyncWriteExt;
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?;
let mut request = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
if !proxy_user.is_empty() {
use base64::Engine as _;
let auth = format!("{}:{}", proxy_user, proxy_password);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).await?;
let mut response = [0u8; 1024];
let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?;
let resp_str = String::from_utf8_lossy(&response[..n]);
if !resp_str.contains("200") && !resp_str.contains("established") {
return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str));
}
Ok(Box::new(stream))
}
_ => {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
Ok(Box::new(stream))
}
}
}
pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)> {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, _, _) = {
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
(
session.proxy_type.clone(),
session.proxy_host.clone(),
session.proxy_port,
session.proxy_user.clone(),
session.proxy_password.clone(),
)
} else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() {
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
(
env_p.proxy_type.clone(),
env_p.host.clone(),
env_p.port,
env_p.user.clone(),
env_p.pass.clone(),
)
} else if config.cache.use_proxy {
(
config.cache.global_proxy_type.clone(),
config.cache.global_proxy_host.clone(),
config.cache.global_proxy_port,
config.cache.global_proxy_user.clone(),
config.cache.global_proxy_password.clone(),
)
} else {
("none".to_string(), String::new(), None, String::new(), String::new())
}
};
if proxy_type != "none" && !proxy_host.is_empty() && proxy_port.is_some() {
Some((proxy_type, proxy_host, proxy_port))
} else {
None
}
}
+154 -131
View File
@@ -1,7 +1,7 @@
pub mod config;
use gpui::{
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, SharedString, Window, px,
};
use gpui_component::{Theme, WindowExt as _, input::InputState};
@@ -11,10 +11,9 @@ use uuid::Uuid;
use self::config::{AuthMethod, Session};
use crate::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup,
Ashell, PaneLayout, SelectorEntry, TabGroup,
app::constants::{
DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X,
TERMINAL_PADDING_Y,
DEFAULT_COLS, DEFAULT_ROWS,
},
backend::{local, ssh},
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
@@ -79,6 +78,17 @@ impl Ashell {
return;
}
if self.ssh_proxy_type != "none" {
let proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
let proxy_port_str = self.proxy_port_input.read(cx).value().trim().to_string();
let proxy_port = proxy_port_str.parse::<u16>().ok();
if proxy_host.is_empty() || proxy_port.is_none() {
self.status = "Proxy host and port are required".into();
cx.notify();
return;
}
}
let name = if session_name.is_empty() {
host.clone()
} else {
@@ -100,13 +110,23 @@ impl Ashell {
}
Session::key(host, port, user, key_path, key_inline, passphrase)
}
AuthMethod::KeyboardInteractive => Session::keyboard_interactive(host, port, user),
};
session.name = name;
if let Some(id) = existing_id {
session.id = id;
}
session.last_used = existing_last_used;
session.proxy_type = self.ssh_proxy_type.clone();
session.proxy_host = self.proxy_host_input.read(cx).value().trim().to_string();
session.proxy_port = self
.proxy_port_input
.read(cx)
.value()
.trim()
.parse::<u16>()
.ok();
session.proxy_user = self.proxy_user_input.read(cx).value().trim().to_string();
session.proxy_password = self.proxy_password_input.read(cx).value().to_string();
self.config.upsert(session.clone());
if let Err(err) = self.config.save() {
tracing::warn!("failed to save config: {err:#}");
@@ -139,6 +159,11 @@ impl Ashell {
Self::set_input_value(&self.key_path_input, "", window, cx);
Self::set_input_value(&self.key_inline_input, "", window, cx);
Self::set_input_value(&self.passphrase_input, "", window, cx);
self.ssh_proxy_type = "none".to_string();
Self::set_input_value(&self.proxy_host_input, "", window, cx);
Self::set_input_value(&self.proxy_port_input, "", window, cx);
Self::set_input_value(&self.proxy_user_input, "", window, cx);
Self::set_input_value(&self.proxy_password_input, "", window, cx);
}
pub(crate) fn load_session_into_form(
@@ -172,6 +197,20 @@ impl Ashell {
window,
cx,
);
self.ssh_proxy_type = if session.proxy_type.is_empty() {
"none".to_string()
} else {
session.proxy_type.clone()
};
Self::set_input_value(&self.proxy_host_input, session.proxy_host.clone(), window, cx);
Self::set_input_value(
&self.proxy_port_input,
session.proxy_port.map(|p| p.to_string()).unwrap_or_default(),
window,
cx,
);
Self::set_input_value(&self.proxy_user_input, session.proxy_user.clone(), window, cx);
Self::set_input_value(&self.proxy_password_input, session.proxy_password.clone(), window, cx);
}
pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -297,6 +336,19 @@ impl Ashell {
cx.notify();
}
pub(crate) fn change_cursor_style(
&mut self,
style: crate::session::config::CursorStyle,
cx: &mut Context<Self>,
) {
self.cursor_style = style;
self.config.set_cursor_style(style);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save cursor style: {err:#}");
}
cx.notify();
}
pub(crate) fn reset_layout(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.config.set_layout_state(None, None, None);
let _ = self.config.save();
@@ -313,6 +365,11 @@ impl Ashell {
cx.notify();
}
pub(crate) fn set_ssh_proxy_type(&mut self, proxy_type: String, cx: &mut Context<Self>) {
self.ssh_proxy_type = proxy_type;
cx.notify();
}
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
tracing::info!(
"[ui] user clicked to connect saved session '{}'",
@@ -439,6 +496,12 @@ impl Ashell {
self.events_tx.clone(),
));
self.active_tab = Some(id.clone());
self.connection_progress = Some(crate::app::ConnectionProgress {
tab_id: id.clone(),
title: rust_i18n::t!("connecting").into(),
lines: vec![rust_i18n::t!("starting_connection").into()],
failed: false,
});
self.pane_root = PaneLayout::Single(id.clone());
self.focused_pane_path = vec![];
let group_id = Uuid::new_v4().to_string();
@@ -478,12 +541,6 @@ impl Ashell {
self.sftp_handles.insert(group_id.clone(), sftp_handle);
self.active_tab = Some(id.clone());
self.pending_sftp_path_sync = Some("/".into());
self.connection_progress = Some(ConnectionProgress {
tab_id: id,
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tab opened".into();
cx.notify();
}
@@ -497,57 +554,57 @@ impl Ashell {
cx.notify();
}
pub(crate) fn retry_connection_progress(&mut self, cx: &mut Context<Self>) {
let Some(progress) = self.connection_progress.clone() else {
/// Retry a single disconnected tab by its ID.
/// For SSH tabs: spawns a new SSH connection and restarts SFTP.
/// For local tabs: spawns a new local shell.
///
/// The existing `TerminalTab` (including its `term` scrollback history)
/// is preserved — only the backend is swapped via `set_backend()`.
pub(crate) fn retry_disconnected_tab(&mut self, tab_id: &str, cx: &mut Context<Self>) {
let Some(ix) = self.tabs.iter().position(|t| t.id == tab_id) else {
return;
};
self.connection_progress = None;
let mut groups_to_restart_sftp = std::collections::HashSet::new();
let mut retry_tabs = Vec::new();
for (ix, tab) in self.tabs.iter().enumerate() {
if !tab.connected && tab.session.is_some() {
retry_tabs.push((ix, tab.id.clone(), tab.session.clone().unwrap()));
}
}
if retry_tabs.is_empty() {
cx.notify();
if self.tabs[ix].connected || self.tabs[ix].disconnected_reason.is_none() {
return;
}
for (ix, tab_id, session) in retry_tabs {
// Close old backend
self.tabs[ix].backend.send(BackendCommand::Close);
let is_ssh = self.tabs[ix].session.is_some();
let session = self.tabs[ix].session.clone();
let new_generation = self.tabs[ix].backend_generation + 1;
let cols = self.tabs[ix].cols;
let rows = self.tabs[ix].rows;
// Spawn new backend
// Close old backend (sends Close through the shared Arc<Mutex>)
self.tabs[ix].send_backend(BackendCommand::Close);
if let Some(session) = session {
// SSH tab: spawn new SSH connection
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
tab_id.clone(),
tab_id.to_string(),
session.clone(),
DEFAULT_COLS,
DEFAULT_ROWS,
cols,
rows,
self.events_tx.clone(),
);
// Replace tab state in-place to reuse the UI component
self.tabs[ix] =
TerminalTab::new_ssh(tab_id.clone(), &session, backend, self.events_tx.clone());
// Swap the backend — the Term's internal listener shares the
// same Arc<Mutex<BackendTx>>, so user input is automatically
// routed to the new backend. Terminal history is preserved.
self.tabs[ix].set_backend(backend);
self.tabs[ix].connected = false;
self.tabs[ix].status = "connecting".into();
self.tabs[ix].disconnected_reason = None;
self.tabs[ix].backend_generation = new_generation;
self.tabs[ix].backend_initialized = false;
// Find group to restart SFTP
// Restart SFTP for the group containing this tab
if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&tab_id))
.find(|g| g.pane_root.contains(tab_id))
{
groups_to_restart_sftp.insert(group.id.clone());
}
}
// Restart SFTP for affected groups
for group_id in groups_to_restart_sftp {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
// Use the session of any tab in that group
let group_id = group.id.clone();
let group_session = self
.tabs
.iter()
@@ -555,48 +612,57 @@ impl Ashell {
.and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group.id) {
if let Some(old_handle) = self.sftp_handles.remove(&group_id) {
old_handle.close();
}
let sftp_handle = crate::sftp::spawn_sftp(
self.runtime.handle(),
group.id.clone(),
group_id.clone(),
session,
self.events_tx.clone(),
);
self.sftp_handles.insert(group.id.clone(), sftp_handle);
self.sftp_handles.insert(group_id.clone(), sftp_handle);
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
}
}
}
} else {
// Local tab: spawn new local shell
match local::spawn_local_terminal(
tab_id.to_string(),
cols,
rows,
self.events_tx.clone(),
) {
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].disconnected_reason = None;
self.tabs[ix].backend_generation = new_generation;
self.tabs[ix].backend_initialized = false;
// Resize the new PTY to match the pane dimensions.
self.tabs[ix].send_backend(BackendCommand::Resize { cols, rows });
}
Err(err) => {
self.status = format!("failed to reopen local terminal: {err:#}").into();
cx.notify();
return;
}
}
}
self.connection_progress = Some(ConnectionProgress {
tab_id: progress.tab_id.clone(),
title: t!("connecting").into(),
lines: vec![t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tabs retrying".into();
cx.notify();
}
pub(crate) fn cancel_connection_progress(&mut self, cx: &mut Context<Self>) {
if self.connection_progress.is_none() {
return;
}
self.connection_progress = None;
let tabs_to_close: Vec<_> = self
.tabs
.iter()
.filter(|tab| !tab.connected && tab.session.is_some())
.map(|tab| tab.id.clone())
.collect();
for id in tabs_to_close {
self.handle_tab_close(id);
self.status = if is_ssh {
"ssh tab retrying"
} else {
"local tab reopened"
}
.into();
cx.notify();
}
@@ -660,7 +726,7 @@ impl Ashell {
id
);
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.remove(ix);
}
return;
@@ -719,7 +785,7 @@ impl Ashell {
.collect();
for tab_id in &tab_ids {
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.retain(|t| t.id != *tab_id);
}
}
@@ -731,7 +797,7 @@ impl Ashell {
} else {
// Just remove this tab from the group
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs[ix].send_backend(BackendCommand::Close);
self.tabs.retain(|t| t.id != id);
}
if let Some(g) = self
@@ -832,6 +898,18 @@ impl Ashell {
}
}
if event.button == MouseButton::Left {
if self.config.right_click_copy_paste() {
if let Some(text) = self.active_terminal_selection_text() {
if !text.is_empty() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
if let Some(active_id) = &self.active_tab {
if let Some(tab) = self.tabs.iter_mut().find(|tab| &tab.id == active_id) {
tab.clear_selection();
}
}
}
}
}
self.begin_terminal_selection(event, cx);
}
cx.notify();
@@ -880,64 +958,9 @@ impl Ashell {
format!("{}@{}:{}", session.user, session.host, session.port)
}
pub(crate) fn sync_terminal_size(&mut self, window: &Window, cx: &App) {
let viewport = window.viewport_size();
let sidebar_width = self
.workspace_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(SIDEBAR_WIDTH);
// Use the whole terminal panel bounds for PTY sizing.
// Individual pane bounds are smaller when split, so using them here
// would shrink the root PTY dimensions a second time.
let (width, height) = if let Some(bounds) = self.terminal_panel_bounds {
(bounds.size.width.as_f32(), bounds.size.height.as_f32())
} else {
let terminal_height = self
.body_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
(
(viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
.max(self.terminal_cell_width()),
(terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height()),
)
};
let total_cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
let total_rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
Self::resize_pane_tree(&mut self.tabs, &self.pane_root, total_cols, total_rows);
}
fn resize_pane_tree(tabs: &mut [TerminalTab], layout: &PaneLayout, cols: u16, rows: u16) {
match layout {
PaneLayout::Single(id) => {
if let Some(tab) = tabs.iter_mut().find(|t| t.id == *id) {
tab.resize(cols.max(1), rows.max(1));
}
}
PaneLayout::Horizontal(children, _) => {
let n = children.len() as u16;
let each_rows = (rows / n).max(1);
for child in children {
Self::resize_pane_tree(tabs, child, cols, each_rows);
}
}
PaneLayout::Vertical(children, _) => {
let n = children.len() as u16;
let each_cols = (cols / n).max(1);
for child in children {
Self::resize_pane_tree(tabs, child, each_cols, rows);
}
}
}
}
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
tracing::info!(
+11 -42
View File
@@ -218,9 +218,13 @@ pub fn spawn_sftp(
.await
{
let _ = events.send(BackendEvent::SftpStatus {
tab_id,
tab_id: tab_id.clone(),
text: format!("sftp error: {err:#}"),
});
let _ = events.send(BackendEvent::Closed {
tab_id,
reason: format!("sftp error: {err:#}"),
});
}
});
SftpHandle {
@@ -824,10 +828,13 @@ async fn connect_and_authenticate(
) -> Result<Arc<russh::client::Handle<SftpClientHandler>>> {
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
keepalive_interval: Some(std::time::Duration::from_secs(3)),
keepalive_max: 2,
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
let mut handle = client::connect(config, addr.as_str(), SftpClientHandler)
let stream = crate::session::config::connect_proxy(session).await?;
let mut handle = client::connect_stream(config, stream, SftpClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
@@ -868,34 +875,6 @@ async fn connect_and_authenticate(
}
success
}
AuthMethod::KeyboardInteractive => {
let cached_kb_responses = {
let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.kb_responses.clone())
};
let responses = cached_kb_responses.unwrap_or_else(|| vec![session.password.clone()]);
let mut response = handle.authenticate_keyboard_interactive_start(&session.user, None).await?;
loop {
match response {
russh::client::KeyboardInteractiveAuthResponse::Success => {
break true;
}
russh::client::KeyboardInteractiveAuthResponse::Failure => {
break false;
}
russh::client::KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
let mut resp = responses.clone();
if resp.len() < prompts.len() {
resp.resize(prompts.len(), String::new());
} else if resp.len() > prompts.len() {
resp.truncate(prompts.len());
}
response = handle.authenticate_keyboard_interactive_respond(resp).await?;
}
}
}
}
};
if !authed {
@@ -907,7 +886,6 @@ async fn connect_and_authenticate(
match session.auth {
AuthMethod::Password => "password",
AuthMethod::Key => "public key",
AuthMethod::KeyboardInteractive => "keyboard interactive",
},
session.user,
session.host,
@@ -921,17 +899,8 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let cached_passphrase = {
let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
let cache = cache_lock.lock().unwrap();
cache.get(&session.id).and_then(|c| c.passphrase.clone())
};
let passphrase = if let Some(ref p) = cached_passphrase {
Some(p.as_str())
} else {
let p = session.passphrase.trim();
(!p.is_empty()).then_some(p)
};
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
-2
View File
@@ -335,5 +335,3 @@ pub fn paint_custom_block(
painted
}
+74 -15
View File
@@ -132,6 +132,7 @@ pub struct TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
@@ -271,6 +272,7 @@ impl TerminalElement {
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
) -> Self {
Self {
@@ -282,6 +284,7 @@ impl TerminalElement {
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
}
}
@@ -375,7 +378,7 @@ impl TerminalElement {
selection_contains(selection, render_cell.row, render_cell.col)
});
let bg = color_to_hsla(cell.bg, false, cx);
if selected || !is_default_bg(cell.bg) {
if selected || !is_default_bg(cell.bg) || cell.flags.contains(Flags::INVERSE) {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
@@ -464,11 +467,35 @@ impl TerminalElement {
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
self.snapshot.cursor.map(|cursor| CursorLayout {
row: cursor.row,
col: cursor.col,
shape: cursor.shape,
color: cx.theme().primary,
use crate::session::config::CursorStyle;
let cursor_style = self.view.read(cx).cursor_style;
let show_cursor = match cursor_style {
CursorStyle::Blink | CursorStyle::BeamBlink => {
if let Ok(duration) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
(duration.as_millis() / 600) % 2 == 0
} else {
true
}
}
_ => true,
};
self.snapshot.cursor.map(|cursor| {
let mut shape = match cursor_style {
CursorStyle::Default => cursor.shape,
CursorStyle::Blink => CursorShape::Block,
CursorStyle::Beam => CursorShape::Beam,
CursorStyle::BeamBlink => CursorShape::Beam,
};
if !show_cursor {
shape = CursorShape::Hidden;
}
CursorLayout {
row: cursor.row,
col: cursor.col,
shape,
color: cx.theme().primary,
}
})
}
}
@@ -517,6 +544,32 @@ impl Element for TerminalElement {
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs, custom_blocks) = 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.
let view = self.view.clone();
let tab_id = self.tab_id.clone();
let _ = 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();
}
});
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -540,18 +593,24 @@ impl Element for TerminalElement {
window: &mut Window,
cx: &mut App,
) {
// Compute a vertical offset to center the text grid vertically,
// distributing the leftover pixel remainder evenly to the top and bottom.
let grid_height = prepaint.metrics.line_height * (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32()).floor();
let y_offset = ((prepaint.bounds.size.height - grid_height) / 2.0).max(px(0.0));
let draw_origin = point(prepaint.bounds.origin.x, prepaint.bounds.origin.y + y_offset);
for rect in &prepaint.rects {
rect.paint(prepaint.bounds.origin, prepaint.metrics, window);
rect.paint(draw_origin, prepaint.metrics, window);
}
for run in &prepaint.runs {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
run.paint(draw_origin, prepaint.metrics, window, cx);
}
for block in &prepaint.custom_blocks {
let x = prepaint.bounds.origin.x.as_f32()
let x = draw_origin.x.as_f32()
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = prepaint.bounds.origin.y.as_f32()
let y = draw_origin.y.as_f32()
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
@@ -568,7 +627,7 @@ impl Element for TerminalElement {
&self.focus_handle,
TerminalInputHandler {
view: self.view.clone(),
element_bounds: prepaint.bounds,
element_bounds: Bounds::new(draw_origin, prepaint.bounds.size),
cell_width: prepaint.metrics.cell_width.as_f32(),
line_height: prepaint.metrics.line_height.as_f32(),
},
@@ -578,8 +637,8 @@ impl Element for TerminalElement {
if let Some(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
if let Some(cursor) = prepaint.cursor {
let pos = point(
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32,
draw_origin.y + prepaint.metrics.line_height * cursor.row as f32,
);
let mut base_style = self.base_text_style(cx);
base_style.underline = Some(UnderlineStyle {
@@ -626,8 +685,8 @@ impl Element for TerminalElement {
{
return;
}
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32;
let x = draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32;
let y = draw_origin.y + prepaint.metrics.line_height * cursor.row as f32;
match cursor.shape {
CursorShape::Hidden => {}
CursorShape::Beam => {
+383 -109
View File
@@ -32,15 +32,59 @@ impl HslaExt for Hsla {
#[derive(Debug, Clone)]
struct HighlightColors {
error: Hsla,
success: Hsla,
warning: Hsla,
info: Hsla,
failure: Hsla,
network: Hsla,
url: Hsla,
port: Hsla,
debug: Hsla,
// Log levels
error: Hsla, // ERROR, ERR
critical: Hsla, // PANIC, FATAL, EMERGENCY, CRITICAL
warning: Hsla, // WARNING, WARN
info: Hsla, // INFO, NOTICE
debug: Hsla, // DEBUG, TRACE, DBG
alert: Hsla, // ALERT
// Status indicators
success: Hsla, // SUCCESS, OK, PASS, DONE, COMPLETED
failure: Hsla, // FAILED, FAIL, FAILURE
pending: Hsla, // PENDING, WAITING, PROCESSING
running: Hsla, // RUNNING, ACTIVE, EXECUTING
stopped: Hsla, // STOPPED, INACTIVE, HALTED, IDLE
skipped: Hsla, // SKIPPED, SKIP
// Network
network_up: Hsla, // UP, ONLINE, CONNECTED
network_down: Hsla, // DOWN, OFFLINE, UNREACHABLE
timeout: Hsla, // TIMEOUT, TIMED OUT
refused: Hsla, // REFUSED, REJECTED, DENIED
// Security & Auth
security: Hsla, // SSH, SSL, TLS, CERTIFICATE
auth: Hsla, // AUTHENTICATED, AUTHORIZED, LOGIN
danger: Hsla, // ROOT, SUDO, PASSWORD, SECRET
// Operations
started: Hsla, // START, BOOT, STARTING
stopped_op: Hsla, // STOP, SHUTDOWN, STOPPING
restart: Hsla, // RESTART, RESTARTING
deploy: Hsla, // DEPLOY, DEPLOYED, DEPLOYMENT
crashed: Hsla, // CRASH, CRASHED, SIGSEGV
// Resources
memory: Hsla, // MEMORY, RAM, SWAP, HEAP
cpu: Hsla, // CPU, PROCESSOR, CORE
disk: Hsla, // DISK, STORAGE, PARTITION, MOUNT
// HTTP codes
http_2xx: Hsla, // 200-299 Success
http_3xx: Hsla, // 300-399 Redirect
http_4xx: Hsla, // 400-499 Client Error
http_5xx: Hsla, // 500-599 Server Error
// Dev / Exceptions
exception: Hsla, // Exception, Traceback, Error type
deprecated: Hsla, // DEPRECATED, TODO, FIXME
// Existing
network: Hsla, // IP addresses
url: Hsla, // http://, https://
port: Hsla, // :22, :443, etc.
}
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
@@ -55,20 +99,154 @@ fn hsla(r: u8, g: u8, b: u8) -> Hsla {
fn highlight_colors() -> HighlightColors {
HighlightColors {
error: hsla(224, 96, 96), // #E06060 red
success: hsla(126, 198, 153), // #7EC699 green
warning: hsla(232, 201, 122), // #E8C97A yellow
info: hsla(108, 180, 238), // #6CB4EE blue
failure: hsla(232, 168, 124), // #E8A87C orange
network: hsla(199, 146, 234), // #C792EA purple
url: hsla( 86, 212, 199), // #56D4C7 teal
port: hsla(130, 170, 200), // #82AAC8 muted teal
debug: hsla(130, 140, 155), // #828C9B gray
// Log levels
error: hsla(224, 96, 96), // #E06060 red
critical: hsla(255, 50, 50), // #FF3232 bright red
warning: hsla(232, 201, 122), // #E8C97A yellow
info: hsla(108, 180, 238), // #6CB4EE blue
debug: hsla(130, 140, 155), // #828C9B gray
alert: hsla(213, 126, 234), // #D57EEA bright magenta
// Status
success: hsla(126, 198, 153), // #7EC699 green
failure: hsla(232, 168, 124), // #E8A87C orange
pending: hsla(232, 201, 122), // #E8C97A yellow
running: hsla( 86, 206, 234), // #56CEEA cyan
stopped: hsla(160, 165, 175), // #A0A5AF gray
skipped: hsla(199, 146, 234), // #C792EA purple
// Network
network_up: hsla(126, 198, 153), // #7EC699 green
network_down: hsla(224, 96, 96), // #E06060 red
timeout: hsla(213, 126, 234), // #D57EEA magenta
refused: hsla(245, 160, 80), // #F5A050 orange
// Security
security: hsla( 86, 206, 234), // #56CEEA cyan
auth: hsla(126, 198, 153), // #7EC699 green
danger: hsla(180, 50, 50), // #B43232 dark red
// Operations
started: hsla(126, 198, 153), // #7EC699 green
stopped_op: hsla(224, 96, 96), // #E06060 red
restart: hsla(232, 201, 122), // #E8C97A yellow
deploy: hsla(100, 210, 140), // #64D28C bright green
crashed: hsla(255, 50, 50), // #FF3232 bright red
// Resources
memory: hsla(199, 146, 234), // #C792EA purple
cpu: hsla( 86, 206, 234), // #56CEEA cyan
disk: hsla(108, 180, 238), // #6CB4EE blue
// HTTP codes
http_2xx: hsla(126, 198, 153), // #7EC699 green
http_3xx: hsla( 86, 206, 234), // #56CEEA cyan
http_4xx: hsla(232, 201, 122), // #E8C97A yellow
http_5xx: hsla(224, 96, 96), // #E06060 red
// Dev
exception: hsla(224, 96, 96), // #E06060 red
deprecated: hsla(245, 160, 80), // #F5A050 orange
// Existing
network: hsla(199, 146, 234), // #C792EA purple
url: hsla( 86, 212, 199), // #56D4C7 teal
port: hsla(130, 170, 200), // #82AAC8 muted teal
}
}
fn is_boundary(c: char) -> bool {
!c.is_ascii_alphanumeric() && c != '_'
/// 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).
fn highlight_keywords(
map: &mut HashMap<(i32, i32), Hsla>,
text: &str,
byte_to_col: &[i32],
row_i32: i32,
keywords: &[&str],
color: Hsla,
) {
for &kw in keywords {
let kw_lower: Vec<u8> = kw.bytes().map(|b| b.to_ascii_lowercase()).collect();
let text_bytes = text.as_bytes();
let text_lower: Vec<u8> = text_bytes.iter().map(|b| b.to_ascii_lowercase()).collect();
let mut start = 0;
while start + kw_lower.len() <= text_lower.len() {
if text_lower[start..].starts_with(&kw_lower) {
let abs = start;
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);
}
start = abs + kw.len();
} else {
start += 1;
}
}
}
}
/// Highlight HTTP status codes (200, 301, 404, 500, etc.)
/// Only matches specific common HTTP codes, not all 3-digit numbers.
fn highlight_http_codes(
map: &mut HashMap<(i32, i32), Hsla>,
text: &str,
byte_to_col: &[i32],
row_i32: i32,
colors: &HighlightColors,
) {
let bytes = text.as_bytes();
let len = bytes.len();
// Specific HTTP codes to highlight
const HTTP_CODES: &[(u16, bool)] = &[
// 2xx
(200, true), (201, true), (202, true), (204, true), (206, true),
// 3xx
(301, true), (302, true), (304, true), (307, true), (308, true),
// 4xx
(400, true), (401, true), (403, true), (404, true), (405, true),
(408, true), (409, true), (410, true), (422, true), (429, true),
// 5xx
(500, true), (502, true), (503, true), (504, true),
];
for i in 0..len.saturating_sub(2) {
if !bytes[i].is_ascii_digit() || !bytes[i + 1].is_ascii_digit() || !bytes[i + 2].is_ascii_digit() {
continue;
}
let code: u16 = ((bytes[i] - b'0') as u16) * 100
+ ((bytes[i + 1] - b'0') as u16) * 10
+ ((bytes[i + 2] - b'0') as u16);
// Only match specific codes
if !HTTP_CODES.iter().any(|&(c, _)| c == code) {
continue;
}
// Must be at a boundary (not part of a longer number)
let before_ok = i == 0 || !bytes[i - 1].is_ascii_digit();
let after_ok = i + 3 >= len || !bytes[i + 3].is_ascii_digit();
if !before_ok || !after_ok {
continue;
}
let color = match code {
200..=299 => colors.http_2xx,
300..=399 => colors.http_3xx,
400..=499 => colors.http_4xx,
500..=599 => colors.http_5xx,
_ => continue,
};
let start_col = byte_to_col[i];
let end_col = byte_to_col[(i + 2).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(color);
}
}
}
pub fn highlight_cells(
@@ -77,7 +255,6 @@ pub fn highlight_cells(
) -> HashMap<(i32, i32), Hsla> {
let colors = highlight_colors();
// Pre-allocate the outer vector to the size of rows.
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
for rc in cells {
if rc.row < 0 || (rc.row as usize) >= rows {
@@ -91,7 +268,6 @@ pub fn highlight_cells(
let mut map = HashMap::new();
// Reusable buffers to avoid allocation inside the loop
let mut chars_buf = String::with_capacity(128);
let mut byte_to_col: Vec<i32> = Vec::with_capacity(128);
@@ -112,96 +288,210 @@ pub fn highlight_cells(
}
let text = chars_buf.as_str();
// ── 1. Error keywords ──────────────────────────
for kw in &["EMERGENCY", "CRITICAL", "FATAL", "PANIC", "ERROR", "ERR"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.error);
}
}
}
// ── 1. Critical errors (highest priority) ──────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["PANIC", "EMERGENCY", "FATAL", "SEGFAULT", "CRITICAL",
"OOM", "OUT OF MEMORY", "KERNEL PANIC", "CORE DUMPED", "BUS ERROR"],
colors.critical,
);
// ── 2. Success keywords ───────────────────────────
for kw in &["SUCCESS", "SUCCEEDED", "PASSED", "PASS", "OK"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.success);
}
}
}
// ── 2. Error keywords ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ERROR", "ERR"], colors.error);
// ── 3. Failure keywords ───────────────────────────
for kw in &["FAILED", "FAILURE", "DENIED", "REJECTED", "TIMEOUT", "FAIL"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.failure);
}
}
}
// ── 3. Alert ───────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ALERT"], colors.alert);
// ── 4. Warning keywords ───────────────────────────
for kw in &["WARNING", "WARN"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.warning);
}
}
}
// ── 4. Warning keywords ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["WARNING", "WARN"], colors.warning);
// ── 5. Info keywords ──────────────────────────────
for kw in &["NOTICE", "INFO"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.info);
}
}
}
// ── 5. Info keywords ───────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["INFO", "INFORMATION", "NOTICE"], colors.info);
// ── 6. Debug keywords ─────────────────────────────
for kw in &["DEBUG", "DBG", "TRACE"] {
for m in find_keyword(text, kw) {
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.debug);
}
}
}
// ── 6. Debug keywords ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEBUG", "DBG", "TRACE"], colors.debug);
// ── 7. IP addresses ───────────────────────────────
// ── 7. Success status ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SUCCESS", "SUCCEEDED", "SUCCESSFUL", "PASSED", "PASS",
"OK", "DONE", "COMPLETED", "FINISHED", "COMPLETE"],
colors.success,
);
// ── 8. Failure status ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["FAILED", "FAILURE", "FAIL", "NOT OK"],
colors.failure,
);
// ── 9. Pending / Waiting ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["PENDING", "WAITING", "PROCESSING", "IN PROGRESS", "QUEUED"],
colors.pending,
);
// ── 10. Running / Active ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["RUNNING", "ACTIVE", "EXECUTING", "IN_PROGRESS", "LIVE"],
colors.running,
);
// ── 11. Stopped / Inactive ─────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STOPPED", "INACTIVE", "HALTED", "IDLE", "PAUSED"],
colors.stopped,
);
// ── 12. Skipped ────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SKIPPED", "SKIP", "SKIPPING"], colors.skipped);
// ── 13. Network UP ─────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["UP", "ONLINE", "CONNECTED", "REACHABLE", "LISTENING",
"ESTABLISHED", "LINK UP"],
colors.network_up,
);
// ── 14. Network DOWN ───────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DOWN", "OFFLINE", "UNREACHABLE", "DISCONNECTED",
"NOT LISTENING", "LINK DOWN", "NO CARRIER"],
colors.network_down,
);
// ── 15. Timeout ────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["TIMEOUT", "TIMED OUT", "TIMEOUTS", "ETIMEDOUT", "SLOW", "LATENCY"],
colors.timeout,
);
// ── 16. Refused / Denied ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["REFUSED", "REJECTED", "DENIED", "PERMISSION DENIED",
"ACCESS DENIED", "FORBIDDEN", "BLOCKED", "DROP"],
colors.refused,
);
// ── 17. Security / Protocol ────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SSH", "SSHD", "SSL", "TLS", "HTTPS", "CERTIFICATE", "CERT",
"FIREWALL", "IPTABLES", "ACL", "WAF"],
colors.security,
);
// ── 18. Authentication ─────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["AUTHENTICATED", "ACCEPTED", "AUTHORIZED", "LOGIN", "LOGOUT",
"LOGGED IN", "LOGGED OUT", "SESSION"],
colors.auth,
);
// ── 19. Danger (root/sudo/secrets) ─────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ROOT", "SUDO", "UID=0", "PASSWORD", "SECRET", "TOKEN",
"API_KEY", "APIKEY", "PRIVATE KEY", "CREDENTIALS"],
colors.danger,
);
// ── 20. Operations: Start ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STARTED", "START", "STARTING", "BOOT", "BOOTING", "LAUNCHED", "LAUNCH"],
colors.started,
);
// ── 21. Operations: Stop ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STOPPED", "STOP", "STOPPING", "SHUTDOWN", "SHUTTING DOWN", "TERMINATED"],
colors.stopped_op,
);
// ── 22. Operations: Restart ────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["RESTARTED", "RESTART", "RESTARTING", "RELOAD", "RELOADED", "RELOADING"],
colors.restart,
);
// ── 23. Operations: Deploy ─────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEPLOYED", "DEPLOYMENT", "DEPLOYING", "DEPLOY",
"ROLLBACK", "ROLLED BACK", "ROLLING BACK", "UPGRADE", "UPGRADED"],
colors.deploy,
);
// ── 24. Operations: Crash ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["CRASH", "CRASHED", "CRASHING", "SIGSEGV", "SIGABRT", "SIGKILL",
"DIED", "EXITED", "EXIT CODE", "CORE DUMP"],
colors.crashed,
);
// ── 25. Resources: Memory ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["MEMORY", "RAM", "HEAP", "STACK", "SWAP", "MEM"],
colors.memory,
);
// ── 26. Resources: CPU ─────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["CPU", "PROCESSOR", "CORE", "CORES", "THREAD", "THREADS", "LOAD"],
colors.cpu,
);
// ── 27. Resources: Disk ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DISK", "STORAGE", "PARTITION", "MOUNT", "FILESYSTEM",
"INODE", "IOPS", "READ", "WRITE"],
colors.disk,
);
// ── 28. Dev: Exceptions ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["EXCEPTION", "TRACEBACK", "THROW", "THROWN", "STACKTRACE",
"TYPEERROR", "VALUEERROR", "KEYERROR", "ATTRIBUTEERROR",
"INDEXERROR", "RUNTIMEERROR", "IOERROR", "OSERROR",
"NULLPOINTER", "NPE", "SEGFAULT"],
colors.exception,
);
// ── 29. Dev: Deprecated / TODO ─────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEPRECATED", "TODO", "FIXME", "HACK", "XXX", "WORKAROUND", "TEMPORARY"],
colors.deprecated,
);
// ── 30. HTTP status codes ──────────────────────────────
highlight_http_codes(&mut map, text, &byte_to_col, row_i32, &colors);
// ── 31. IP addresses ───────────────────────────────────
for m in find_ip_addresses(text) {
let ip_len = find_ip_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + find_ip_len(&text[m..])).min(byte_to_col.len() - 1)];
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);
}
}
// ── 8. URLs ───────────────────────────────────────
// ── 32. URLs ───────────────────────────────────────────
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + url_len).min(byte_to_col.len() - 1)];
let end_col = byte_to_col[(m + url_len - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.url);
}
}
// ── 9. Port numbers ─────────────────────────────────────
// ── 33. Port numbers ───────────────────────────────────
for m in find_ports(text) {
let port_len = find_port_len(&text[m..]);
let start_col = byte_to_col[m];
let end_col = byte_to_col[(m + port_len).min(byte_to_col.len() - 1)];
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);
}
@@ -211,26 +501,6 @@ pub fn highlight_cells(
map
}
fn find_keyword(text: &str, keyword: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
while let Some(pos) = text[start..].find(keyword) {
let abs = start + pos;
let before_ok = abs == 0
|| text.as_bytes()[abs - 1] == b' '
|| is_boundary(text.as_bytes()[abs - 1] as char);
let after_pos = abs + keyword.len();
let after_ok = after_pos >= text.len()
|| text.as_bytes()[after_pos] == b' '
|| is_boundary(text.as_bytes()[after_pos] as char);
if before_ok && after_ok {
positions.push(abs);
}
start = abs + keyword.len();
}
positions
}
fn find_ip_len(text: &str) -> usize {
let bytes = text.as_bytes();
let mut dots = 0u8;
@@ -255,7 +525,7 @@ fn find_ip_len(text: &str) -> usize {
}
digits = 0;
}
_ => break,
_ => break, // Stop at first non-digit/non-dot (including '/')
}
len += 1;
}
@@ -292,6 +562,10 @@ fn find_ip_addresses(text: &str) -> Vec<usize> {
positions
}
fn is_boundary(c: char) -> bool {
!c.is_ascii_alphanumeric() && c != '_'
}
fn find_urls(text: &str) -> Vec<usize> {
let mut positions = Vec::new();
let mut start = 0;
+62 -10
View File
@@ -112,6 +112,38 @@ impl Ashell {
}
}
// If the active tab is disconnected and user presses Enter, reconnect
if event.keystroke.key == "enter"
&& !event.keystroke.modifiers.shift
&& !event.keystroke.modifiers.control
&& !event.keystroke.modifiers.alt
&& !event.keystroke.modifiers.platform
{
if let Some(progress) = &self.connection_progress {
if progress.failed {
self.retry_connection_progress(cx);
window.prevent_default();
cx.stop_propagation();
return;
}
}
let active_id = self.active_tab.clone();
if let Some(active_id) = active_id {
let is_disconnected = self
.tabs
.iter()
.find(|t| t.id == active_id)
.is_some_and(|tab| tab.disconnected_reason.is_some());
if is_disconnected {
self.retry_disconnected_tab(&active_id, cx);
window.prevent_default();
cx.stop_propagation();
return;
}
}
}
if event.prefer_character_input {
if let Some(text) = event.keystroke.key_char.as_deref() {
if !text.is_empty()
@@ -138,7 +170,7 @@ impl Ashell {
tab.clear_selection();
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
tab.backend.send(BackendCommand::Input(bytes));
tab.send_backend(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
@@ -176,13 +208,13 @@ impl Ashell {
}
tab.clear_selection();
tab.backend.send(BackendCommand::Input(bytes));
tab.send_backend(BackendCommand::Input(bytes));
window.prevent_default();
cx.stop_propagation();
cx.notify();
}
fn active_terminal_selection_text(&self) -> Option<String> {
pub(crate) fn active_terminal_selection_text(&self) -> Option<String> {
let active_id = self.active_tab.as_ref()?;
self.tabs
.iter()
@@ -190,7 +222,12 @@ impl Ashell {
.and_then(|tab| tab.selection_text())
}
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn paste_into_terminal(
&mut self,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(active_id) = self.active_tab.clone() else {
return;
};
@@ -258,8 +295,7 @@ impl Ashell {
}
tab.clear_selection();
self.terminal_marked_text = None;
tab.backend
.send(BackendCommand::Input(text.as_bytes().to_vec()));
tab.send_backend(BackendCommand::Input(text.as_bytes().to_vec()));
window.invalidate_character_coordinates();
cx.notify();
}
@@ -459,6 +495,24 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
// Platform modifier (Cmd on macOS, Ctrl on Windows/Linux) + scroll → zoom terminal font size
if event.modifiers.platform {
let delta = match event.delta {
ScrollDelta::Lines(point) => point.y as f32 * 20.0,
ScrollDelta::Pixels(point) => point.y.as_f32(),
};
self.terminal_zoom_accumulator += delta;
let step = 20.0;
if self.terminal_zoom_accumulator.abs() >= step {
let zoom_steps = (self.terminal_zoom_accumulator / step).trunc();
self.terminal_zoom_accumulator -= zoom_steps * step;
self.change_terminal_font_size(zoom_steps * 0.5, cx);
}
window.prevent_default();
cx.stop_propagation();
return;
}
let Some(active_id) = self.active_tab.clone() else {
return;
};
@@ -517,8 +571,7 @@ impl Ashell {
}
}
if !bytes.is_empty() {
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
tab.send_backend(crate::terminal::BackendCommand::Input(bytes));
}
}
window.prevent_default();
@@ -532,8 +585,7 @@ impl Ashell {
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
}
if !bytes.is_empty() {
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
tab.send_backend(crate::terminal::BackendCommand::Input(bytes));
}
window.prevent_default();
cx.stop_propagation();
+56 -35
View File
@@ -25,35 +25,16 @@ pub enum TabKind {
Ssh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptType {
KeyboardInteractive,
Passphrase,
}
#[derive(Debug, Clone)]
pub struct PromptInfo {
pub prompt: String,
pub echo: bool,
}
#[derive(Debug)]
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
PromptResponse(Vec<String>),
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
PromptRequest {
tab_id: String,
prompt_type: PromptType,
instruction: String,
prompts: Vec<PromptInfo>,
},
Output {
tab_id: String,
bytes: Vec<u8>,
@@ -138,12 +119,20 @@ pub struct TerminalTab {
pub kind: TabKind,
pub status: String,
pub connected: bool,
pub disconnected_reason: Option<String>,
/// Incremented each time the tab is reconnected. Used to ignore stale
/// `BackendEvent::Closed` from the previous backend after a retry.
pub backend_generation: u32,
/// Set to `true` when the current backend sends its first `Output` or
/// `Connected` event. Used to skip stale `Closed` events that arrive
/// before the new backend has started producing output.
pub backend_initialized: bool,
pub session: Option<Session>,
processor: Processor,
term: Term<TerminalListener>,
cols: u16,
rows: u16,
pub backend: BackendTx,
pub cols: u16,
pub rows: u16,
pub backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
pub scroll_pixel_y: f32,
pub(crate) highlight_cache: std::cell::RefCell<Option<(Vec<RenderCell>, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>,
}
@@ -241,18 +230,22 @@ impl TerminalTab {
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Self {
let shared_backend = std::sync::Arc::new(std::sync::Mutex::new(backend));
Self {
id: id.clone(),
title,
kind,
status,
connected: matches!(kind, TabKind::Local),
disconnected_reason: None,
backend_generation: 0,
backend_initialized: true,
session: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone(), id, events),
term: new_term(100, 30, shared_backend.clone(), id, events),
cols: 100,
rows: 30,
backend,
backend: shared_backend,
scroll_pixel_y: 0.0,
highlight_cache: std::cell::RefCell::new(None),
}
@@ -262,6 +255,22 @@ impl TerminalTab {
self.processor.advance(&mut self.term, bytes);
}
/// Send a command to the backend. Thread-safe via the shared Arc<Mutex>.
pub fn send_backend(&self, command: BackendCommand) {
if let Ok(backend) = self.backend.lock() {
backend.send(command);
}
}
/// Replace the backend with a new one. The `Term`'s internal listener
/// shares the same `Arc`, so user input is automatically routed to the
/// new backend. The old backend must be closed by the caller.
pub fn set_backend(&self, new_backend: BackendTx) {
if let Ok(mut backend) = self.backend.lock() {
*backend = new_backend;
}
}
pub fn resize(&mut self, cols: u16, rows: u16) {
let new_cols = cols.max(1);
let new_rows = rows.max(1);
@@ -274,7 +283,7 @@ impl TerminalTab {
self.rows
);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
self.send_backend(BackendCommand::Resize { cols, rows });
}
}
@@ -339,7 +348,7 @@ impl TerminalTab {
let highlights = if is_enabled {
let mut cache = self.highlight_cache.borrow_mut();
let cache_valid = cache.as_ref().map_or(false, |(cached_cells, _)| {
let cache_valid = cache.as_ref().is_some_and(|(cached_cells, _)| {
cached_cells == &cells
});
if cache_valid {
@@ -462,13 +471,22 @@ impl TerminalTab {
}
pub fn paste_text(&mut self, text: &str) {
let bracketed = self.term.mode().contains(TermMode::BRACKETED_PASTE);
let paste_text = text
.replace('\x1b', "")
.replace("\r\n", "\r")
.replace('\n', "\r");
self.backend
.send(BackendCommand::Input(paste_text.into_bytes()));
let mut bytes = Vec::new();
if bracketed {
bytes.extend_from_slice(b"\x1b[200~");
}
bytes.extend_from_slice(paste_text.as_bytes());
if bracketed {
bytes.extend_from_slice(b"\x1b[201~");
}
self.send_backend(BackendCommand::Input(bytes));
}
}
@@ -518,16 +536,18 @@ fn viewport_selection_from_range(
#[derive(Clone)]
struct TerminalListener {
tab_id: String,
backend: BackendTx,
backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
events: std::sync::mpsc::Sender<BackendEvent>,
}
impl EventListener for TerminalListener {
fn send_event(&self, event: Event) {
match event {
Event::PtyWrite(output) => self
.backend
.send(BackendCommand::Input(output.into_bytes())),
Event::PtyWrite(output) => {
if let Ok(backend) = self.backend.lock() {
backend.send(BackendCommand::Input(output.into_bytes()));
}
}
Event::TextAreaSizeRequest(format) => {
let size = alacritty_terminal::event::WindowSize {
num_lines: 30,
@@ -535,8 +555,9 @@ impl EventListener for TerminalListener {
cell_width: 8,
cell_height: 16,
};
self.backend
.send(BackendCommand::Input(format(size).into_bytes()));
if let Ok(backend) = self.backend.lock() {
backend.send(BackendCommand::Input(format(size).into_bytes()));
}
}
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
@@ -552,7 +573,7 @@ impl EventListener for TerminalListener {
fn new_term(
cols: u16,
rows: u16,
backend: BackendTx,
backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
tab_id: String,
events: std::sync::mpsc::Sender<BackendEvent>,
) -> Term<TerminalListener> {