mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
11
Commits
v0.4.8
...
v0.4.8-fix2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65354e8d6 | ||
|
|
fcfbefe3ab | ||
|
|
39d369aaaf | ||
|
|
5c0c0cc701 | ||
|
|
99b7fe30e4 | ||
|
|
24f817f9e0 | ||
|
|
1a3f638004 | ||
|
|
5f318f66c8 | ||
|
|
8854ee9afb | ||
|
|
8cc64acdc1 | ||
|
|
77a0380ba7 |
@@ -198,7 +198,6 @@ 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"
|
||||
@@ -210,4 +209,3 @@ global_proxy_port: "Proxy Port"
|
||||
global_proxy_user: "Proxy Username"
|
||||
global_proxy_password: "Proxy Password"
|
||||
save_proxy: "Save Proxy"
|
||||
|
||||
|
||||
@@ -200,7 +200,6 @@ sync_downloading: "正在下载并解密配置..."
|
||||
sync_upload_complete: "配置上传完成"
|
||||
sync_download_complete: "配置下载完成"
|
||||
sync_failed: "同步失败"
|
||||
keyboard_interactive: "键盘交互"
|
||||
settings_proxy: "代理设置"
|
||||
enable_proxy: "启用代理功能"
|
||||
read_env_proxy: "启动时读取环境变量"
|
||||
@@ -212,4 +211,3 @@ 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";
|
||||
|
||||
+18
-128
@@ -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},
|
||||
@@ -70,10 +70,7 @@ impl Ashell {
|
||||
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";
|
||||
@@ -102,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| {
|
||||
@@ -116,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| {
|
||||
@@ -139,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()
|
||||
@@ -1483,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
|
||||
@@ -1505,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")))
|
||||
@@ -1544,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
|
||||
@@ -1557,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")))
|
||||
@@ -2026,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")),
|
||||
)
|
||||
@@ -2047,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.send_backend(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.send_backend(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)
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+141
-29
@@ -197,7 +197,6 @@ pub(crate) enum DialogKind {
|
||||
SessionSelector,
|
||||
Transfers,
|
||||
NewSsh,
|
||||
PromptRequest,
|
||||
}
|
||||
|
||||
pub(crate) struct Ashell {
|
||||
@@ -243,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>,
|
||||
@@ -259,6 +259,8 @@ pub(crate) struct Ashell {
|
||||
pub(crate) tabs_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) selector_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) saved_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) connection_scroll_handle: gpui::ScrollHandle,
|
||||
pub(crate) connection_progress: Option<ConnectionProgress>,
|
||||
pub(crate) pending_sftp_path_sync: Option<String>,
|
||||
pub(crate) sftp_context_menu: Option<SftpContextMenuState>,
|
||||
pub(crate) sftp_creating_folder: bool,
|
||||
@@ -325,6 +327,14 @@ pub(crate) enum SelectorEntry {
|
||||
Saved(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ConnectionProgress {
|
||||
pub(crate) tab_id: String,
|
||||
pub(crate) title: SharedString,
|
||||
pub(crate) lines: Vec<SharedString>,
|
||||
pub(crate) failed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SftpContextMenuState {
|
||||
pub(crate) remote_path: String,
|
||||
@@ -585,6 +595,7 @@ 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,
|
||||
@@ -605,6 +616,8 @@ impl Ashell {
|
||||
tabs_scroll_handle: gpui::ScrollHandle::new(),
|
||||
selector_scroll_handle: gpui::ScrollHandle::new(),
|
||||
saved_scroll_handle: gpui::ScrollHandle::new(),
|
||||
connection_scroll_handle: gpui::ScrollHandle::new(),
|
||||
connection_progress: None,
|
||||
pending_sftp_path_sync: Some("/".into()),
|
||||
sftp_context_menu: None,
|
||||
sftp_creating_folder: false,
|
||||
@@ -673,7 +686,7 @@ impl Ashell {
|
||||
|
||||
this.apply_theme_preferences(window, cx);
|
||||
// this.open_local(cx);
|
||||
this.start_event_pump(window, cx);
|
||||
this.start_event_pump(cx);
|
||||
this
|
||||
}
|
||||
|
||||
@@ -735,20 +748,18 @@ 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);
|
||||
let is_blinking = matches!(
|
||||
this.cursor_style,
|
||||
@@ -770,9 +781,9 @@ impl Ashell {
|
||||
idle_frames = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if update_res.is_err() {
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -780,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() {
|
||||
@@ -797,6 +808,14 @@ impl Ashell {
|
||||
tab.backend_initialized = true;
|
||||
tab.status = text.clone();
|
||||
}
|
||||
if let Some(progress) = self.connection_progress.as_mut() {
|
||||
if progress.tab_id == tab_id {
|
||||
progress.lines.push(text.clone().into());
|
||||
let _idx = progress.lines.len().saturating_sub(1);
|
||||
self.connection_scroll_handle
|
||||
.set_offset(gpui::point(px(0.), px(-99999.0)));
|
||||
}
|
||||
}
|
||||
self.status = text.into();
|
||||
}
|
||||
BackendEvent::Connected { tab_id } => {
|
||||
@@ -807,21 +826,13 @@ impl Ashell {
|
||||
}
|
||||
self.sync_system_tab_to_active_group();
|
||||
self.request_active_system_snapshot();
|
||||
}
|
||||
BackendEvent::PromptRequest {
|
||||
tab_id,
|
||||
prompt_type,
|
||||
instruction,
|
||||
prompts,
|
||||
} => {
|
||||
self.show_interactive_prompt_dialog(
|
||||
tab_id,
|
||||
prompt_type,
|
||||
instruction,
|
||||
prompts,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
if self
|
||||
.connection_progress
|
||||
.as_ref()
|
||||
.is_some_and(|progress| progress.tab_id == tab_id && !progress.failed)
|
||||
{
|
||||
self.connection_progress = None;
|
||||
}
|
||||
}
|
||||
BackendEvent::SftpEntries {
|
||||
tab_id,
|
||||
@@ -912,6 +923,16 @@ impl Ashell {
|
||||
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
|
||||
self.system_status = Some(reason.clone().into());
|
||||
}
|
||||
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(gpui::point(px(0.), px(-99999.0)));
|
||||
progress.title = t!("connection_failed").into();
|
||||
progress.failed = true;
|
||||
}
|
||||
}
|
||||
self.status = reason.into();
|
||||
}
|
||||
BackendEvent::TransferProgress {
|
||||
@@ -1084,6 +1105,97 @@ impl Ashell {
|
||||
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.");
|
||||
|
||||
+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(())
|
||||
}
|
||||
|
||||
+140
-24
@@ -2270,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()
|
||||
@@ -2320,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 {
|
||||
@@ -2336,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| {
|
||||
@@ -2357,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),
|
||||
@@ -2433,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()
|
||||
@@ -2477,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()
|
||||
@@ -2596,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);
|
||||
@@ -2774,22 +2773,30 @@ impl Render for Ashell {
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &crate::Copy, window, cx| {
|
||||
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();
|
||||
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();
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
} else {
|
||||
cx.propagate();
|
||||
}
|
||||
}))
|
||||
.on_action(cx.listener(|this, _: &crate::Paste, window, cx| {
|
||||
if let Some(clipboard) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard.text() {
|
||||
this.paste_into_terminal(&text, 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| {
|
||||
@@ -2903,6 +2910,115 @@ impl Render for Ashell {
|
||||
),
|
||||
)
|
||||
})
|
||||
.when_some(self.connection_progress.clone(), |this, progress| {
|
||||
this.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.bg(gpui::Hsla {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
l: 0.0,
|
||||
a: 0.48,
|
||||
})
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
div()
|
||||
.w(px(420.))
|
||||
.p_5()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().popover)
|
||||
.shadow_lg()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
Button::new("ssh-connect-progress")
|
||||
.primary()
|
||||
.loading(!progress.failed)
|
||||
.label(progress.title.clone()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.min_h(px(0.))
|
||||
.max_h(px(220.))
|
||||
.child(
|
||||
div()
|
||||
.id("connection-progress-scroll")
|
||||
.max_h(px(220.))
|
||||
.overflow_hidden()
|
||||
.overflow_y_scroll()
|
||||
.track_scroll(&self.connection_scroll_handle)
|
||||
.child(
|
||||
v_flex().gap_2().children(
|
||||
progress.lines.iter().cloned().map(|line| {
|
||||
div()
|
||||
.text_size(rems(1.0))
|
||||
.text_color(if progress.failed {
|
||||
cx.theme().danger
|
||||
} else {
|
||||
cx.theme().muted_foreground
|
||||
})
|
||||
.child(line)
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.right_0()
|
||||
.bottom_0()
|
||||
.w(px(16.))
|
||||
.child(
|
||||
Scrollbar::vertical(&self.connection_scroll_handle)
|
||||
.scrollbar_show(ScrollbarShow::Scrolling)
|
||||
)
|
||||
)
|
||||
)
|
||||
.when(progress.failed, |this| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.justify_end()
|
||||
.gap_2()
|
||||
.child(
|
||||
Button::new("ssh-connect-progress-retry")
|
||||
.primary()
|
||||
.label(t!("retry").to_string())
|
||||
.on_click(cx.listener(
|
||||
|this, _, _, cx| {
|
||||
this.retry_connection_progress(
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Button::new("ssh-connect-progress-close")
|
||||
.label(t!("cancel").to_string())
|
||||
.on_click(cx.listener(
|
||||
|this, _, _, cx| {
|
||||
this.cancel_connection_progress(
|
||||
cx,
|
||||
)
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.on_prepaint({
|
||||
let view = cx.entity().clone();
|
||||
move |_, window, cx| {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+3
-167
@@ -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,27 +213,10 @@ 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)),
|
||||
@@ -313,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(),
|
||||
@@ -397,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 {
|
||||
@@ -495,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
-29
@@ -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,7 +30,7 @@ pub struct Session {
|
||||
pub passphrase: String,
|
||||
#[serde(default)]
|
||||
pub last_used: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_global_proxy_type")]
|
||||
pub proxy_type: String, // "none", "socks5", "http"
|
||||
#[serde(default)]
|
||||
pub proxy_host: String,
|
||||
@@ -94,32 +93,6 @@ impl Session {
|
||||
proxy_password: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+13
-60
@@ -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};
|
||||
@@ -13,8 +13,7 @@ use self::config::{AuthMethod, Session};
|
||||
use crate::{
|
||||
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},
|
||||
@@ -111,7 +110,6 @@ 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 {
|
||||
@@ -199,7 +197,11 @@ impl Ashell {
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
self.ssh_proxy_type = session.proxy_type.clone();
|
||||
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,
|
||||
@@ -494,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();
|
||||
@@ -950,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!(
|
||||
|
||||
+2
-40
@@ -875,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 {
|
||||
@@ -914,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,
|
||||
@@ -928,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();
|
||||
|
||||
|
||||
+45
-10
@@ -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,
|
||||
@@ -541,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 {
|
||||
@@ -564,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,
|
||||
@@ -592,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(),
|
||||
},
|
||||
@@ -602,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 {
|
||||
@@ -650,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 => {
|
||||
|
||||
+33
-1
@@ -119,6 +119,15 @@ impl Ashell {
|
||||
&& !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
|
||||
@@ -213,7 +222,12 @@ impl Ashell {
|
||||
.and_then(|tab| tab.selection_text())
|
||||
}
|
||||
|
||||
pub(crate) 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;
|
||||
};
|
||||
@@ -481,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;
|
||||
};
|
||||
|
||||
+11
-20
@@ -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>,
|
||||
@@ -490,12 +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.send_backend(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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user