Compare commits

...
18 Commits
Author SHA1 Message Date
TomZz af5c2831f6 fix: remove redundant window drag area to fix window control overlay (resolves #73) 2026-07-04 12:11:42 +08:00
TomZz a8669738e6 fix: relocate window title bar dragging to tab bar container (resolves #72) 2026-07-03 19:55:22 +08:00
RabitLogic 2dad1fa5a1 fix: enable window title bar dragging on Linux via manual mouse tracking and deb packaging (#68) 2026-07-03 16:51:18 +08:00
TomZz e2bf76f7b2 bump version to v0.4.9 2026-07-03 04:56:05 +08:00
TomZz a654695d7b feat: support terminal URL highlighting on hover, click-to-open and multi-line wrapping 2026-07-03 04:36:42 +08:00
TomZz cb078ef572 feat: add setting to lock window layout (resolves #69) 2026-07-02 23:39:03 +08:00
TomZz c0aa21a994 fix: enable window title bar dragging in integrated mode (resolves #67) 2026-07-02 23:15:07 +08:00
TomZz c65354e8d6 fix(ui): resolve terminal split-screen sizing alignment and remainder margin gap 2026-06-29 17:13:35 +08:00
TomZz fcfbefe3ab fix: do not label Maple Mono as built-in if loaded from system 2026-06-27 18:32:50 +08:00
TomZz 39d369aaaf fix: conditionally load built-in fonts if not present in system (resolves #65) 2026-06-27 18:28:25 +08:00
TomZz 5c0c0cc701 fix: default empty or missing session proxy type to 'none' 2026-06-27 18:23:46 +08:00
TomZz 99b7fe30e4 fix: restrict terminal copy/paste actions to terminal focus 2026-06-27 18:21:28 +08:00
TomZz 24f817f9e0 revert: remove manual keyboard-interactive authentication settings and dialog 2026-06-27 18:13:37 +08:00
TomZz 1a3f638004 fix: support bracketed paste mode (fixes #62) 2026-06-27 11:48:17 +08:00
TomZz 5f318f66c8 fix: draw background rectangle for cells with INVERSE flag (fixes #66) 2026-06-27 11:44:29 +08:00
TomZz 8854ee9afb fix: restore connection progress dialog and support Enter to retry 2026-06-27 00:37:33 +08:00
TomZz 8cc64acdc1 fix: smooth terminal font zooming using scroll accumulator 2026-06-27 00:23:20 +08:00
peace-young 77a0380ba7 feat: add Ctrl+scroll to zoom terminal font size (#64) 2026-06-27 00:08:23 +08:00
24 changed files with 1813 additions and 547 deletions
Generated
+1 -1
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.8"
version = "0.4.9"
dependencies = [
"alacritty_terminal",
"anyhow",
+13 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.8"
version = "0.4.9"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -54,6 +54,18 @@ rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
[package.metadata.deb]
maintainer = "ashell contributors"
license-file = ["LICENSE"]
extended-description = "A GPUI Component based SSH and local terminal client"
section = "terminal"
priority = "optional"
assets = [
["target/release/ashell", "usr/bin/", "755"],
["assets/ashell.desktop", "usr/share/applications/", "644"],
["assets/icons/ashell.png", "usr/share/icons/hicolor/256x256/apps/", "644"],
]
[profile.release]
opt-level = 3
lto = "thin"
+33
View File
@@ -82,6 +82,39 @@ open target/release/ashell.app
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
## Package Linux (Debian/Ubuntu)
### Prerequisites
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### Build .deb
```bash
cargo build --release
cargo deb
```
The `.deb` file will be generated at:
```
target/debian/ashell_0.4.8-1_amd64.deb
```
### Install
```bash
sudo dpkg -i target/debian/ashell_0.4.8-1_amd64.deb
```
After installation, you can launch from the application menu or by running `ashell` in the terminal. The `.deb` package includes:
- `/usr/bin/ashell` — Main binary
- `/usr/share/applications/ashell.desktop` — Desktop entry
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — Application icon
## License
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
+33
View File
@@ -82,6 +82,39 @@ open target/release/ashell.app
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## 打包 Linux (Debian/Ubuntu)
### 前置条件
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### 构建 .deb 包
```bash
cargo build --release
cargo deb
```
生成的 `.deb` 文件位于:
```
target/debian/ashell_0.4.9-1_amd64.deb
```
### 安装
```bash
sudo dpkg -i target/debian/ashell_0.4.9-1_amd64.deb
```
安装后可通过应用菜单或命令行 `ashell` 启动。`.deb` 包包含以下内容:
- `/usr/bin/ashell` — 主程序
- `/usr/share/applications/ashell.desktop` — 桌面入口
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — 应用图标
## 协议
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Ashell
Comment=A GPUI Component based SSH and local terminal client
Exec=ashell
Icon=ashell
Terminal=false
Type=Application
Categories=System;TerminalEmulator;Utility;
StartupWMClass=ashell
+2 -2
View File
@@ -125,6 +125,8 @@ software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
keyword_highlight: "Terminal Keyword Highlighting"
lock_layout: "Lock Window Layout"
lock_layout_hint: "Lock the position of the sidebar and the SFTP panel."
copy_paste_hint: "Selected text can also be copied by left-clicking."
settings_copy: "Copy Selection"
settings_paste: "Paste Clipboard"
@@ -198,7 +200,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 +211,3 @@ global_proxy_port: "Proxy Port"
global_proxy_user: "Proxy Username"
global_proxy_password: "Proxy Password"
save_proxy: "Save Proxy"
+2 -2
View File
@@ -126,6 +126,8 @@ software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
keyword_highlight: "终端关键词高亮"
lock_layout: "锁定窗口布局"
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
@@ -200,7 +202,6 @@ sync_downloading: "正在下载并解密配置..."
sync_upload_complete: "配置上传完成"
sync_download_complete: "配置下载完成"
sync_failed: "同步失败"
keyboard_interactive: "键盘交互"
settings_proxy: "代理设置"
enable_proxy: "启用代理功能"
read_env_proxy: "启动时读取环境变量"
@@ -212,4 +213,3 @@ global_proxy_port: "代理端口"
global_proxy_user: "代理用户名"
global_proxy_password: "代理密码"
save_proxy: "保存代理设置"
+5
View File
@@ -2,7 +2,12 @@ pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0;
#[allow(dead_code)]
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+37 -128
View File
@@ -1,5 +1,5 @@
use gpui::{
Anchor, AppContext as _, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
prelude::FluentBuilder as _, px, rems,
};
@@ -8,7 +8,7 @@ use gpui_component::{
button::{Button, ButtonVariants as _},
dialog::Dialog,
h_flex,
input::{Input, InputState},
input::Input,
menu::{DropdownMenu as _, PopupMenuItem},
progress::Progress,
scroll::{Scrollbar, ScrollbarShow},
@@ -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(&current) {
t!("system_default").to_string()
} else if current == "Maple Mono NF CN" {
} else if !using_system_maple && current == "Maple Mono NF CN" {
format!("Maple Mono NF CN ({})", t!("software_builtin"))
} else {
current
@@ -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")))
@@ -1679,6 +1666,25 @@ impl Ashell {
})
)
)
.item(
SettingItem::new(
t!("lock_layout").to_string(),
SettingField::render({
let view = view_clone_for_general.clone();
move |_, window, cx| {
Switch::new("lock-layout")
.small()
.checked(view.read(cx).config.lock_layout())
.on_click(window.listener_for(&view, |this, checked, _, cx| {
this.config.set_lock_layout(*checked);
let _ = this.config.save();
cx.notify();
}))
.into_any_element()
}
})
).description(t!("lock_layout_hint").to_string())
)
.item(
SettingItem::new(
t!("monitoring_position").to_string(),
@@ -2026,7 +2032,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 +2053,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)
})
});
}
}
+156 -30
View File
@@ -6,6 +6,7 @@ pub mod search;
pub mod startup;
pub mod theme;
pub mod ui;
pub mod resizable;
use std::{
cell::{Cell, RefCell},
@@ -23,9 +24,9 @@ use gpui::{
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
input::{InputEvent, InputState},
resizable::ResizableState,
scroll::ScrollbarHandle,
};
use crate::app::resizable::ResizableState;
use rust_i18n::t;
use tokio::runtime::Runtime;
@@ -197,7 +198,6 @@ pub(crate) enum DialogKind {
SessionSelector,
Transfers,
NewSsh,
PromptRequest,
}
pub(crate) struct Ashell {
@@ -243,6 +243,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 +260,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,
@@ -315,9 +318,19 @@ pub(crate) struct Ashell {
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) should_move_window: bool,
pub(crate) hovered_url: Option<HoveredUrl>,
pub(crate) cmd_ctrl_pressed: bool,
pub(crate) _subscriptions: Vec<gpui::Subscription>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct HoveredUrl {
pub(crate) url: String,
pub(crate) tab_id: String,
pub(crate) cells: Vec<(usize, usize)>,
}
#[derive(Clone)]
pub(crate) enum SelectorEntry {
Local,
@@ -325,6 +338,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 +606,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 +627,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,
@@ -668,12 +692,15 @@ impl Ashell {
events_tx,
last_window_size: None,
last_sidebar_width,
should_move_window: false,
hovered_url: None,
cmd_ctrl_pressed: false,
_subscriptions,
};
this.apply_theme_preferences(window, cx);
// this.open_local(cx);
this.start_event_pump(window, cx);
this.start_event_pump(cx);
this
}
@@ -735,20 +762,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 +795,9 @@ impl Ashell {
idle_frames = 0;
}
}
});
});
if update_res.is_err() {
})
.is_err()
{
break;
}
}
@@ -780,7 +805,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 +822,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 +840,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 +937,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 +1119,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.");
+329
View File
@@ -0,0 +1,329 @@
#![allow(dead_code)]
use std::ops::Range;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub enum ResizablePanelEvent {
Resized,
}
/// Create a [`ResizablePanelGroup`] with horizontal resizing
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
}
/// Create a [`ResizablePanelGroup`] with vertical resizing
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Vertical)
}
/// Create a [`ResizablePanel`].
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}
/// State for a [`ResizablePanel`]
#[derive(Debug, Clone)]
pub struct ResizableState {
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
axis: Axis,
pub(crate) panels: Vec<ResizablePanelState>,
pub(crate) sizes: Vec<Pixels>,
pub(crate) resizing_panel_ix: Option<usize>,
pub(crate) bounds: Bounds<Pixels>,
}
impl Default for ResizableState {
fn default() -> Self {
Self {
axis: Axis::Horizontal,
panels: vec![],
sizes: vec![],
resizing_panel_ix: None,
bounds: Bounds::default(),
}
}
}
impl ResizableState {
/// Get the size of the panels.
pub fn sizes(&self) -> &Vec<Pixels> {
&self.sizes
}
/// Programmatically resize the panel at `ix` to `size`, redistributing
/// space among siblings using the same logic as a drag.
///
/// Sizes are clamped to the panel's `size_range` and to the container.
/// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference
/// persistence) see the change just as if the user had dragged a handle.
///
/// Out-of-range indices are a no-op. For the last panel, space is taken
/// from the previous sibling (the last panel has no handle of its own).
pub fn resize_panel(
&mut self,
ix: usize,
size: Pixels,
window: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
if ix + 1 < self.sizes.len() {
self.resize_panel_at_handle(ix, size, window, cx);
} else if ix > 0 {
// Last panel: drive its size by resizing the previous sibling so
// the freed space lands here.
let delta = self.sizes[ix] - size;
let prev = self.sizes[ix - 1];
self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
}
self.done_resizing(cx);
}
pub(crate) fn insert_panel(
&mut self,
size: Option<Pixels>,
ix: Option<usize>,
cx: &mut Context<Self>,
) {
let panel_state = ResizablePanelState {
size,
..Default::default()
};
let size = size.unwrap_or(PANEL_MIN_SIZE);
// We make sure that the size always sums up to the container size
// by reducing the size of all other panels first.
let container_size = self.container_size().max(px(1.));
let total_leftover_size = (container_size - size).max(px(1.));
for (i, panel) in self.panels.iter_mut().enumerate() {
let ratio = self.sizes[i] / container_size;
self.sizes[i] = total_leftover_size * ratio;
panel.size = Some(self.sizes[i]);
}
if let Some(ix) = ix {
self.panels.insert(ix, panel_state);
self.sizes.insert(ix, size);
} else {
self.panels.push(panel_state);
self.sizes.push(size);
};
cx.notify();
}
pub(crate) fn sync_panels_count(
&mut self,
axis: Axis,
panels_count: usize,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
self.axis = axis;
if panels_count > self.panels.len() {
let diff = panels_count - self.panels.len();
self.panels
.extend(vec![ResizablePanelState::default(); diff]);
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
changed = true;
}
if panels_count < self.panels.len() {
self.panels.truncate(panels_count);
self.sizes.truncate(panels_count);
changed = true;
}
if changed {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
}
pub(crate) fn update_panel_size(
&mut self,
panel_ix: usize,
bounds: Bounds<Pixels>,
size_range: Range<Pixels>,
cx: &mut Context<Self>,
) {
let size = bounds.size.along(self.axis);
// This check is only necessary to stop the very first panel from resizing on its own
// it needs to be passed when the panel is freshly created so we get the initial size,
// but its also fine when it sometimes passes later.
if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
self.sizes[panel_ix] = size;
self.panels[panel_ix].size = Some(size);
}
self.panels[panel_ix].bounds = bounds;
self.panels[panel_ix].size_range = size_range;
cx.notify();
}
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
if resizing_panel_ix > panel_ix {
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
}
self.adjust_to_container_size(cx);
}
pub(crate) fn replace_panel(
&mut self,
panel_ix: usize,
panel: ResizablePanelState,
cx: &mut Context<Self>,
) {
let old_size = self.sizes[panel_ix];
self.panels[panel_ix] = panel;
self.sizes[panel_ix] = old_size;
self.adjust_to_container_size(cx);
}
pub(crate) fn clear(&mut self) {
self.panels.clear();
self.sizes.clear();
}
#[inline]
pub(crate) fn container_size(&self) -> Pixels {
self.bounds.size.along(self.axis)
}
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
self.resizing_panel_ix = None;
cx.emit(ResizablePanelEvent::Resized);
}
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
let Some(panel) = self.panels.get(ix) else {
return PANEL_MIN_SIZE..Pixels::MAX;
};
panel.size_range.clone()
}
fn sync_real_panel_sizes(&mut self, _: &App) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.bounds.size.along(self.axis);
}
}
pub(crate) fn resize_panel_at_handle(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
let old_sizes = self.sizes.clone();
let mut ix = ix;
if ix >= old_sizes.len() - 1 {
return;
}
let container_size = self.container_size();
self.sync_real_panel_sizes(cx);
let move_changed = size - old_sizes[ix];
if move_changed == px(0.) {
return;
}
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
let is_expand = move_changed > px(0.);
let main_ix = ix;
let mut new_sizes = old_sizes.clone();
if is_expand {
let mut changed = new_size - old_sizes[ix];
new_sizes[ix] = new_size;
while changed > px(0.) && ix < old_sizes.len() - 1 {
ix += 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let mut changed = new_size - size;
new_sizes[ix] = new_size;
while changed > px(0.) && ix > 0 {
ix -= 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
changed -= to_reduce;
new_sizes[ix] -= to_reduce;
}
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
}
let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
}
for (i, _) in old_sizes.iter().enumerate() {
let size = new_sizes[i];
self.panels[i].size = Some(size);
}
self.sizes = new_sizes;
cx.notify();
}
pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
}
let container_size = self.container_size();
let total_size = px(self.sizes.iter().map(|s| s.as_f32()).sum::<f32>());
for i in 0..self.panels.len() {
let size = self.sizes[i];
let ratio = size / total_size;
let new_size = container_size * ratio;
self.sizes[i] = new_size;
self.panels[i].size = Some(new_size);
}
cx.notify();
}
}
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResizablePanelState {
pub size: Option<Pixels>,
pub size_range: Range<Pixels>,
pub(crate) bounds: Bounds<Pixels>,
}
+430
View File
@@ -0,0 +1,430 @@
use std::{
ops::{Deref, Range},
rc::Rc,
};
use gpui::{
Along, AnyElement, App, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
EventEmitter, InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent,
ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled, Window, div,
prelude::FluentBuilder, AppContext as _, IsZero as _,
};
use gpui_component::{
AxisExt, ElementExt, h_flex, v_flex, StyledExt,
};
use super::{ResizableState, resizable_panel, resize_handle, PANEL_MIN_SIZE, ResizablePanelEvent};
#[derive(Clone)]
pub(crate) struct DragPanel;
impl Render for DragPanel {
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
Empty
}
}
/// A group of resizable panels.
#[derive(IntoElement)]
pub struct ResizablePanelGroup {
id: ElementId,
state: Option<Entity<ResizableState>>,
axis: Axis,
size: Option<Pixels>,
children: Vec<ResizablePanel>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
locked: bool,
}
impl ResizablePanelGroup {
/// Create a new resizable panel group.
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
axis: Axis::Horizontal,
children: vec![],
state: None,
size: None,
on_resize: Rc::new(|_, _, _| {}),
locked: false,
}
}
/// Bind yourself to a resizable state entity.
///
/// If not provided, it will handle its own state internally.
pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self {
self.state = Some(state.clone());
self
}
/// Set the axis of the resizable panel group, default is horizontal.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Set lock status of the resizable panel group.
pub fn lock(mut self, locked: bool) -> Self {
self.locked = locked;
self
}
/// Add a panel to the group.
///
/// - The `axis` will be set to the same axis as the group.
/// - The `initial_size` will be set to the average size of all panels if not provided.
/// - The `group` will be set to the group entity.
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
self.children.push(panel.into());
self
}
/// Add multiple panels to the group.
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
where
I: Into<ResizablePanel>,
{
self.children = panels.into_iter().map(|panel| panel.into()).collect();
self
}
/// Set size of the resizable panel group
///
/// - When the axis is horizontal, the size is the height of the group.
/// - When the axis is vertical, the size is the width of the group.
pub fn size(mut self, size: Pixels) -> Self {
self.size = Some(size);
self
}
/// Set the callback to be called when the panels are resized.
///
/// ## Callback arguments
///
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
pub fn on_resize(
mut self,
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resize = Rc::new(on_resize);
self
}
}
impl<T> From<T> for ResizablePanel
where
T: Into<AnyElement>,
{
fn from(value: T) -> Self {
resizable_panel().child(value.into())
}
}
impl From<ResizablePanelGroup> for ResizablePanel {
fn from(value: ResizablePanelGroup) -> Self {
resizable_panel().child(value)
}
}
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
impl RenderOnce for ResizablePanelGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = self.state.unwrap_or_else(|| {
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default())
});
let container = if self.axis.is_horizontal() {
h_flex()
} else {
v_flex()
};
// Sync panels to the state
let panels_count = self.children.len();
state.update(cx, |state, cx| {
state.sync_panels_count(self.axis, panels_count, cx);
});
container
.id(self.id)
.size_full()
.children(
self.children
.into_iter()
.enumerate()
.map(|(ix, mut panel)| {
panel.panel_ix = ix;
panel.axis = self.axis;
panel.state = Some(state.clone());
panel.locked = self.locked;
panel
}),
)
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
let size_changed =
state.bounds.size.along(self.axis) != bounds.size.along(self.axis);
state.bounds = bounds;
if size_changed {
state.adjust_to_container_size(cx);
}
})
}
})
.child(ResizePanelGroupElement {
state: state.clone(),
axis: self.axis,
on_resize: self.on_resize.clone(),
})
}
}
/// A resizable panel inside a [`ResizablePanelGroup`].
#[derive(IntoElement)]
pub struct ResizablePanel {
axis: Axis,
panel_ix: usize,
state: Option<Entity<ResizableState>>,
/// Initial size is the size that the panel has when it is created.
initial_size: Option<Pixels>,
/// size range limit of this panel.
size_range: Range<Pixels>,
children: Vec<AnyElement>,
visible: bool,
style: StyleRefinement,
locked: bool,
}
impl ResizablePanel {
/// Create a new resizable panel.
pub(super) fn new() -> Self {
Self {
panel_ix: 0,
initial_size: None,
state: None,
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
axis: Axis::Horizontal,
children: vec![],
visible: true,
style: StyleRefinement::default(),
locked: false,
}
}
/// Set the visibility of the panel, default is true.
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
/// Set the initial size of the panel.
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.initial_size = Some(size.into());
self
}
/// Set the size range to limit panel resize.
///
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
self.size_range = range.into();
self
}
}
impl Styled for ResizablePanel {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl ParentElement for ResizablePanel {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for ResizablePanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
if !self.visible {
return div().id(("resizable-panel", self.panel_ix));
}
let state = self
.state
.expect("BUG: The `state` in ResizablePanel should be present.");
let panel_state = state
.read(cx)
.panels
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
div()
.id(("resizable-panel", self.panel_ix))
.flex()
.flex_grow_1()
.size_full()
.relative()
.refine_style(&self.style)
.when(self.axis.is_vertical(), |this| {
this.min_h(size_range.start).max_h(size_range.end)
})
.when(self.axis.is_horizontal(), |this| {
this.min_w(size_range.start).max_w(size_range.end)
})
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
.when_some(self.initial_size, |this, initial_size| {
this.when(
panel_state.size.is_none() && !initial_size.is_zero(),
|this| this.flex_none(),
)
.flex_basis(initial_size)
})
.map(|this| match panel_state.size {
Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)),
None => this,
})
.on_prepaint({
let state = state.clone();
move |bounds, _, cx| {
state.update(cx, |state, cx| {
state.update_panel_size(self.panel_ix, bounds, self.size_range, cx)
})
}
})
.children(self.children)
.when(self.panel_ix > 0, |this| {
let ix = self.panel_ix - 1;
let handle = resize_handle(("resizable-handle", ix), self.axis);
let handle = if self.locked {
handle
} else {
handle.on_drag(
DragPanel,
move |drag_panel, _, _, cx| {
cx.stop_propagation();
state.update(cx, |state, _| {
state.resizing_panel_ix = Some(ix);
});
cx.new(|_| drag_panel.deref().clone())
},
)
};
this.child(handle)
})
}
}
struct ResizePanelGroupElement {
state: Entity<ResizableState>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
axis: Axis,
}
impl IntoElement for ResizePanelGroupElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for ResizePanelGroupElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
()
}
fn paint(
&mut self,
_: Option<&gpui::GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.on_mouse_event({
let state = self.state.clone();
let axis = self.axis;
let current_ix = state.read(cx).resizing_panel_ix;
move |e: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let Some(ix) = current_ix else { return };
state.update(cx, |state, cx| {
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
match axis {
Axis::Horizontal => state.resize_panel_at_handle(
ix,
e.position.x - panel.bounds.left(),
window,
cx,
),
Axis::Vertical => state.resize_panel_at_handle(
ix,
e.position.y - panel.bounds.top(),
window,
cx,
),
}
cx.notify();
})
}
});
// When any mouse up, stop dragging
window.on_mouse_event({
let state = self.state.clone();
let current_ix = state.read(cx).resizing_panel_ix;
let on_resize = self.on_resize.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if current_ix.is_none() {
return;
}
if phase.bubble() {
state.update(cx, |state, cx| state.done_resizing(cx));
on_resize(&state, window, cx);
}
}
})
}
}
+222
View File
@@ -0,0 +1,222 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
div, prelude::FluentBuilder as _, px, AnyElement, App, Axis, Element, ElementId, Entity,
GlobalElementId, InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent,
ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window,
};
use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
axis: Axis,
) -> ResizeHandle<T, E> {
ResizeHandle::new(id, axis)
}
pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
id: ElementId,
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
let id = id.into();
Self {
id: id.clone(),
on_drag: None,
drag_value: None,
placement: None,
axis,
}
}
pub(crate) fn on_drag(
mut self,
value: T,
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
) -> Self {
let value = Rc::new(value);
self.drag_value = Some(value.clone());
self.on_drag = Some(Rc::new(move |p, window, cx| {
f(value.clone(), p, window, cx)
}));
self
}
pub(crate) fn placement(mut self, placement: DockPlacement) -> Self {
self.placement = Some(placement);
self
}
}
#[derive(Default, Debug, Clone)]
struct ResizeHandleState {
active: Cell<bool>,
}
impl ResizeHandleState {
fn set_active(&self, active: bool) {
self.active.set(active);
}
fn is_active(&self) -> bool {
self.active.get()
}
}
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
type Element = ResizeHandle<T, E>;
fn into_element(self) -> Self::Element {
self
}
}
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
type RequestLayoutState = AnyElement;
type PrepaintState = ();
fn id(&self) -> Option<ElementId> {
Some(self.id.clone())
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (gpui::LayoutId, Self::RequestLayoutState) {
let neg_offset = -HANDLE_PADDING;
let axis = self.axis;
window.with_element_state(id.unwrap(), |state, window| {
let state: ResizeHandleState = state.unwrap_or_default();
let bg_color = if state.is_active() {
cx.theme().drag_border
} else {
cx.theme().border
};
let has_drag = self.on_drag.is_some();
let mut el = div()
.id(self.id.clone())
.occlude()
.absolute()
.flex_shrink_0()
.group("handle")
.when_some(self.on_drag.clone(), |this, on_drag| {
this.on_drag(
self.drag_value.clone().unwrap(),
move |_, position, window, cx| on_drag(&position, window, cx),
)
})
.map(|this| match self.placement {
Some(DockPlacement::Left) => {
this.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING)
}
_ => this
.when(axis.is_horizontal(), |this| {
this.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.left(neg_offset)
.h_full()
.w(HANDLE_SIZE)
.px(HANDLE_PADDING)
})
.when(axis.is_vertical(), |this| {
this.when(has_drag, |this| this.cursor_row_resize())
.top(neg_offset)
.left_0()
.w_full()
.h(HANDLE_SIZE)
.py(HANDLE_PADDING)
}),
})
.child(
div()
.bg(bg_color)
.group_hover("handle", |this| this.bg(bg_color))
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
)
.into_any_element();
let layout_id = el.request_layout(window, cx);
((layout_id, el), state)
})
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
_: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
request_layout.prepaint(window, cx);
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
_: Option<&gpui::InspectorElementId>,
bounds: gpui::Bounds<Pixels>,
request_layout: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
request_layout.paint(window, cx);
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
let state = state.unwrap_or_default();
if self.on_drag.is_some() {
window.on_mouse_event({
let state = state.clone();
move |ev: &MouseDownEvent, phase, window, _| {
if bounds.contains(&ev.position) && phase.bubble() {
state.set_active(true);
window.refresh();
}
}
});
window.on_mouse_event({
let state = state.clone();
move |_: &MouseUpEvent, _, window, _| {
if state.is_active() {
state.set_active(false);
window.refresh();
}
}
});
}
((), state)
});
}
}
+18 -9
View File
@@ -12,16 +12,25 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
include_str!("../../assets/themes/phygerr.json"),
];
use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
let has_system_maple = cx.text_system().all_font_names().contains(&"Maple Mono NF CN".to_string());
if has_system_maple {
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
} else {
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
}
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
Ok(())
}
+175 -31
View File
@@ -13,11 +13,11 @@ use gpui_component::{
input::Input,
menu::{ContextMenuExt as _, PopupMenuItem},
progress::Progress,
resizable::{h_resizable, resizable_panel, v_resizable},
scroll::{ScrollableElement as _, Scrollbar, ScrollbarShow},
tab::{Tab, TabBar},
v_flex,
};
use crate::app::resizable::{h_resizable, resizable_panel, v_resizable};
use rust_i18n::t;
use crate::{
@@ -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 {
@@ -2333,14 +2334,11 @@ impl Ashell {
let font_size = px(this.terminal_font_size);
let line_height = px(this.terminal_line_height());
let cell_width = px(this.terminal_cell_width());
let is_url_hovered = this.hovered_url.as_ref().map_or(false, |hu| hu.tab_id == *tab_id);
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);
});
})
.when(is_url_hovered, |d| d.cursor_pointer())
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
@@ -2357,6 +2355,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 +2432,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 +2476,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 +2597,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);
@@ -2648,6 +2649,7 @@ impl Render for Ashell {
};
let body_panel = v_resizable("ashell-body")
.lock(self.config.lock_layout())
.with_state(&self.body_panels)
.child(resizable_panel().child(self.render_terminal_panel(window, cx)))
.child(
@@ -2734,6 +2736,7 @@ impl Render for Ashell {
);
h_resizable("ashell-workspace")
.lock(self.config.lock_layout())
.with_state(&self.workspace_panels)
.child(sidebar_area)
.child(main_area)
@@ -2774,22 +2777,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| {
@@ -2801,18 +2812,42 @@ impl Render for Ashell {
.h(px(34.))
.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(
div()
.id("tab-bar-drag")
.flex_1()
.min_w(px(0.))
.h_full()
.on_double_click(|_, window, _| {
#[cfg(target_os = "macos")]
window.titlebar_double_click();
#[cfg(not(target_os = "macos"))]
window.zoom_window();
})
.when(cfg!(target_os = "linux"), |this| {
this.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, _, _| {
this.should_move_window = true;
}),
)
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _, _, _| {
this.should_move_window = false;
}),
)
.on_mouse_down_out(cx.listener(|this, _, _, _| {
this.should_move_window = false;
}))
.on_mouse_move(cx.listener(|this, _, window, _| {
if this.should_move_window {
this.should_move_window = false;
window.start_window_move();
}
}))
})
.child(self.render_tab_bar(cx)),
),
)
@@ -2903,6 +2938,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| {
-1
View File
@@ -117,7 +117,6 @@ pub fn spawn_local_terminal(
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
BackendCommand::PromptResponse(_) => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
+3 -167
View File
@@ -16,23 +16,8 @@ use tokio::sync::mpsc;
use crate::{
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx, PromptType, PromptInfo},
terminal::{BackendCommand, BackendEvent, BackendTx},
};
use std::sync::OnceLock;
use std::collections::HashMap;
use tokio::sync::Mutex;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct CachedCreds {
pub password: Option<String>,
pub passphrase: Option<String>,
pub kb_responses: Option<Vec<String>>,
}
pub static CREDENTIALS_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedCreds>>> = OnceLock::new();
pub static PROMPT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
pub fn spawn_ssh_terminal(
runtime: &tokio::runtime::Handle,
@@ -110,7 +95,7 @@ async fn run_ssh(
});
let handle = Arc::new(tokio::sync::Mutex::new(
connect_and_authenticate(&tab_id, &session, &events, &mut commands).await?,
connect_and_authenticate(&tab_id, &session, &events).await?,
));
let mut channel = handle
@@ -171,9 +156,6 @@ async fn run_ssh(
}
});
}
Some(BackendCommand::PromptResponse(_)) => {
tracing::warn!("[ssh] received unexpected prompt response after authentication");
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
@@ -231,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
),
}
));
}
+13 -29
View File
@@ -6,11 +6,10 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
Password,
Key,
KeyboardInteractive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -31,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)]
@@ -203,6 +176,8 @@ pub struct ConfigFile {
pub transfers: Vec<crate::terminal::Transfer>,
#[serde(default)]
pub show_hidden_files: bool,
#[serde(default)]
pub lock_layout: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
#[serde(default)]
@@ -315,6 +290,7 @@ impl Default for ConfigFile {
body_panels: None,
transfers: Vec::new(),
show_hidden_files: false,
lock_layout: false,
monitoring_position: default_monitoring_position(),
sidebar_collapsed: false,
sftp_panel_minimized: false,
@@ -729,6 +705,14 @@ impl ConfigStore {
self.cache.show_hidden_files = val;
}
pub fn lock_layout(&self) -> bool {
self.cache.lock_layout
}
pub fn set_lock_layout(&mut self, val: bool) {
self.cache.lock_layout = val;
}
pub fn sidebar_collapsed(&self) -> bool {
self.cache.sidebar_collapsed
}
+25 -62
View File
@@ -1,7 +1,7 @@
pub mod config;
use gpui::{
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, SharedString, Window, px,
};
use gpui_component::{Theme, WindowExt as _, input::InputState};
@@ -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,
@@ -352,8 +354,8 @@ impl Ashell {
let _ = self.config.save();
self.is_layout_reset = true;
self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.workspace_panels = cx.new(|_| crate::app::resizable::ResizableState::default());
self.body_panels = cx.new(|_| crate::app::resizable::ResizableState::default());
cx.notify();
}
@@ -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();
@@ -890,6 +898,16 @@ impl Ashell {
}
}
if event.button == MouseButton::Left {
if event.modifiers.platform {
if let Some((row, col, _side)) = self.terminal_grid_point_and_side(event.position) {
if let Some(snapshot) = self.active_snapshot() {
if let Some((url, _)) = crate::terminal::highlight::find_url_at_cell(&snapshot.cells, snapshot.rows, row, col) {
let _ = open::that(&url);
return;
}
}
}
}
if self.config.right_click_copy_paste() {
if let Some(text) = self.active_terminal_selection_text() {
if !text.is_empty() {
@@ -950,64 +968,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
View File
@@ -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();
+117 -13
View File
@@ -39,6 +39,26 @@ impl LayoutRect {
}
}
#[derive(Clone)]
struct LayoutUnderline {
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
impl LayoutUnderline {
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
let thickness = px(1.0);
let position = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * (self.row as f32 + 1.0) - thickness,
);
let size = gpui::size(metrics.cell_width * self.cells as f32, thickness);
window.paint_quad(fill(Bounds::new(position, size), self.color));
}
}
#[derive(Clone)]
struct BatchedTextRun {
row: i32,
@@ -132,6 +152,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>>,
}
@@ -142,6 +163,7 @@ pub struct PrepaintState {
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
underlines: Vec<LayoutUnderline>,
}
#[derive(Clone)]
@@ -271,6 +293,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 +305,7 @@ impl TerminalElement {
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
}
}
@@ -351,10 +375,14 @@ impl TerminalElement {
fn layout_grid(
&self,
cx: &App,
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>, Vec<LayoutUnderline>) {
let view_read = self.view.read(cx);
let hovered_url = view_read.hovered_url.clone();
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut underlines = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
// Retrieve cached keyword highlights and merge with search highlights
@@ -375,7 +403,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,
@@ -408,6 +436,20 @@ impl TerminalElement {
style.color = hl_color;
}
// Apply hover underline if mouse is hovering over this URL
if let Some(hu) = &hovered_url {
if hu.tab_id == self.tab_id
&& hu.cells.contains(&(render_cell.row as usize, render_cell.col as usize))
{
underlines.push(LayoutUnderline {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
color: style.color,
});
}
}
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
@@ -460,7 +502,12 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs, custom_blocks)
(
merge_rects(rects),
runs,
custom_blocks,
merge_underlines(underlines),
)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
@@ -540,7 +587,33 @@ impl Element for TerminalElement {
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs, custom_blocks) = self.layout_grid(cx);
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
// Save the precise GPUI-rendered bounds of this terminal element.
// This is 100% accurate because it is recorded during layout prepaint.
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 {
@@ -551,6 +624,7 @@ impl Element for TerminalElement {
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
underlines,
}
}
@@ -564,18 +638,28 @@ 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 u in &prepaint.underlines {
u.paint(draw_origin, prepaint.metrics, window);
}
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 +676,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 +686,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 +734,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 => {
@@ -711,6 +795,26 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
merged
}
fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline> {
underlines.sort_by_key(|u| (u.row, u.col));
let mut merged: Vec<LayoutUnderline> = Vec::with_capacity(underlines.len());
for u in underlines {
if let Some(last) = merged.last_mut() {
if last.row == u.row
&& last.color == u.color
&& last.col + last.cells as i32 == u.col
{
last.cells += u.cells;
continue;
}
}
merged.push(u);
}
merged
}
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
let row = row.max(0) as usize;
let col = col.max(0) as usize;
+118 -9
View File
@@ -477,15 +477,7 @@ pub fn highlight_cells(
}
}
// ── 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 - 1).min(byte_to_col.len() - 1)];
for c in start_col..=end_col {
map.entry((row_i32, c)).or_insert(colors.url);
}
}
// ── 33. Port numbers ───────────────────────────────────
for m in find_ports(text) {
@@ -497,6 +489,21 @@ pub fn highlight_cells(
}
}
}
// ── 32. URLs (Logical lines for wrapping support) ───────────
let logical_lines = build_logical_lines(cells, rows);
for line in &logical_lines {
let text = line.text.as_str();
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
for i in 0..url_len {
let idx = m + i;
if idx < line.byte_to_cell.len() {
let (r, c) = line.byte_to_cell[idx];
map.entry((r as i32, c as i32)).or_insert(colors.url);
}
}
}
}
map
}
@@ -630,3 +637,105 @@ fn find_port_len(text: &str) -> usize {
}
len
}
#[derive(Clone)]
pub struct LogicalLine<'a> {
pub text: String,
pub byte_to_cell: Vec<(usize, usize)>,
pub row_cells: Vec<&'a RenderCell>,
}
pub fn build_logical_lines<'a>(cells: &'a [RenderCell], rows: usize) -> Vec<LogicalLine<'a>> {
let mut row_chars: Vec<Vec<&RenderCell>> = vec![Vec::with_capacity(128); rows];
for rc in cells {
if rc.row < 0 || (rc.row as usize) >= rows {
continue;
}
row_chars[rc.row as usize].push(rc);
}
for row in row_chars.iter_mut() {
row.sort_by_key(|rc| rc.col);
}
let mut logical_lines = Vec::new();
let mut current_line: Option<LogicalLine> = None;
for (row_idx, row_cells) in row_chars.into_iter().enumerate() {
if row_cells.is_empty() {
if let Some(line) = current_line.take() {
logical_lines.push(line);
}
continue;
}
let wraps_from_prev = row_idx > 0 && {
current_line.as_ref().map_or(false, |line| {
line.row_cells.last().map_or(false, |rc| {
rc.cell.flags.contains(alacritty_terminal::term::cell::Flags::WRAPLINE)
})
})
};
if !wraps_from_prev {
if let Some(line) = current_line.take() {
logical_lines.push(line);
}
}
let mut line = current_line.take().unwrap_or_else(|| LogicalLine {
text: String::with_capacity(128),
byte_to_cell: Vec::with_capacity(128),
row_cells: Vec::new(),
});
for rc in row_cells {
line.text.push(rc.cell.c);
let end_len = line.text.len();
while line.byte_to_cell.len() < end_len {
line.byte_to_cell.push((rc.row as usize, rc.col as usize));
}
line.row_cells.push(rc);
}
current_line = Some(line);
}
if let Some(line) = current_line.take() {
logical_lines.push(line);
}
logical_lines
}
pub fn find_url_at_cell(
cells: &[RenderCell],
rows: usize,
row: usize,
col: usize,
) -> Option<(String, Vec<(usize, usize)>)> {
let logical_lines = build_logical_lines(cells, rows);
for line in logical_lines {
let text = line.text.as_str();
for m in find_urls(text) {
let url_len = find_url_len(&text[m..]);
let mut url_cells = Vec::with_capacity(url_len);
let mut matched = false;
for i in 0..url_len {
let idx = m + i;
if idx < line.byte_to_cell.len() {
let (r, c) = line.byte_to_cell[idx];
if r == row && c == col {
matched = true;
}
url_cells.push((r, c));
}
}
if matched {
let url_str = text[m..m + url_len].to_string();
return Some((url_str, url_cells));
}
}
}
None
}
+59 -2
View File
@@ -23,6 +23,7 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cmd_ctrl_pressed = event.keystroke.modifiers.platform;
// If the search input is focused, skip terminal key processing
// so the input can handle text entry, paste, etc. normally.
if self
@@ -119,6 +120,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 +223,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;
};
@@ -365,6 +380,30 @@ impl Ashell {
}
return;
}
// Track URL hover
let mut hovered_url = None;
let cmd_ctrl_pressed = event.modifiers.platform;
if let Some((row, col, _side)) = self.terminal_grid_point_and_side(event.position) {
if let Some(snapshot) = self.active_snapshot() {
if let Some(active_id) = &self.active_tab {
if let Some((url, url_cells)) = crate::terminal::highlight::find_url_at_cell(&snapshot.cells, snapshot.rows, row, col) {
hovered_url = Some(crate::app::HoveredUrl {
url,
tab_id: active_id.clone(),
cells: url_cells,
});
}
}
}
}
if self.hovered_url != hovered_url || self.cmd_ctrl_pressed != cmd_ctrl_pressed {
self.hovered_url = hovered_url;
self.cmd_ctrl_pressed = cmd_ctrl_pressed;
cx.notify();
}
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
return;
}
@@ -440,7 +479,7 @@ impl Ashell {
cx.notify();
}
fn terminal_grid_point_and_side(
pub(crate) fn terminal_grid_point_and_side(
&self,
position: Point<Pixels>,
) -> Option<(usize, usize, Side)> {
@@ -481,6 +520,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
View File
@@ -25,35 +25,16 @@ pub enum TabKind {
Ssh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptType {
KeyboardInteractive,
Passphrase,
}
#[derive(Debug, Clone)]
pub struct PromptInfo {
pub prompt: String,
pub echo: bool,
}
#[derive(Debug)]
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
PromptResponse(Vec<String>),
}
#[derive(Debug, Clone)]
pub enum BackendEvent {
PromptRequest {
tab_id: String,
prompt_type: PromptType,
instruction: String,
prompts: Vec<PromptInfo>,
},
Output {
tab_id: String,
bytes: Vec<u8>,
@@ -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));
}
}