mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 08:00:20 +00:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65354e8d6 | ||
|
|
fcfbefe3ab | ||
|
|
39d369aaaf | ||
|
|
5c0c0cc701 | ||
|
|
99b7fe30e4 | ||
|
|
24f817f9e0 | ||
|
|
1a3f638004 | ||
|
|
5f318f66c8 | ||
|
|
8854ee9afb | ||
|
|
8cc64acdc1 | ||
|
|
77a0380ba7 | ||
|
|
3770826764 | ||
|
|
cae6323888 | ||
|
|
54bcc97fe1 | ||
|
|
273bc2fe0c | ||
|
|
f0e02e0ce7 | ||
|
|
f6d9cd00e7 | ||
|
|
bfc7c9ac83 | ||
|
|
fd19daedad | ||
|
|
042834c7d5 | ||
|
|
3c6b9e3b46 | ||
|
|
a88379b01c | ||
|
|
f322b2c717 | ||
|
|
45d0c64c06 | ||
|
|
6312a482cb | ||
|
|
8bb4cbc655 |
Generated
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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: "保存代理设置"
|
||||
|
||||
@@ -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
@@ -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(¤t) {
|
||||
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)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -335,5 +335,3 @@ pub fn paint_custom_block(
|
||||
|
||||
painted
|
||||
}
|
||||
|
||||
|
||||
|
||||
+74
-15
@@ -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
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user