mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab41ccbd6b | ||
|
|
43717e4378 | ||
|
|
31218f7a37 | ||
|
|
af5c2831f6 | ||
|
|
a8669738e6 | ||
|
|
2dad1fa5a1 | ||
|
|
e2bf76f7b2 | ||
|
|
a654695d7b | ||
|
|
cb078ef572 | ||
|
|
c0aa21a994 |
Generated
+1
-1
@@ -315,7 +315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.4.8"
|
||||
version = "0.4.10"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+13
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.4.8"
|
||||
version = "0.4.10"
|
||||
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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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) 开源。
|
||||
|
||||
@@ -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
|
||||
@@ -33,6 +33,8 @@ port: "Port"
|
||||
user: "User"
|
||||
password: "Password"
|
||||
key: "Key"
|
||||
ssh_config: "Config(~/.ssh/config)"
|
||||
ssh_config_empty: "No entries found in ~/.ssh/config"
|
||||
private_key_path: "Private Key Path"
|
||||
private_key_data: "Private Key Data"
|
||||
save_and_connect: "Save & Connect"
|
||||
@@ -125,6 +127,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"
|
||||
|
||||
@@ -34,6 +34,8 @@ port: "端口 (Port)"
|
||||
user: "用户 (User)"
|
||||
password: "密码 (Password)"
|
||||
key: "密钥 (Key)"
|
||||
ssh_config: "配置(~/.ssh/config)"
|
||||
ssh_config_empty: "~/.ssh/config 中未找到条目"
|
||||
private_key_path: "私钥路径"
|
||||
private_key_data: "私钥内容"
|
||||
save_and_connect: "保存并连接"
|
||||
@@ -126,6 +128,8 @@ software_builtin: "软件内置"
|
||||
retry: "重试"
|
||||
right_click_copy_paste: "右键复制/粘贴"
|
||||
keyword_highlight: "终端关键词高亮"
|
||||
lock_layout: "锁定窗口布局"
|
||||
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
|
||||
copy_paste_hint: "选中后左键点击也可复制。"
|
||||
settings_copy: "复制选区"
|
||||
settings_paste: "粘贴剪贴板"
|
||||
|
||||
+595
-443
File diff suppressed because it is too large
Load Diff
+64
-15
@@ -2,6 +2,7 @@ pub mod config_sync;
|
||||
pub mod constants;
|
||||
pub mod dialogs;
|
||||
pub mod keybinding_recorder;
|
||||
pub mod resizable;
|
||||
pub mod search;
|
||||
pub mod startup;
|
||||
pub mod theme;
|
||||
@@ -16,6 +17,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use crate::app::resizable::ResizableState;
|
||||
use gpui::{
|
||||
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size,
|
||||
UniformListScrollHandle, Window, point, px, size,
|
||||
@@ -23,7 +25,6 @@ use gpui::{
|
||||
use gpui_component::{
|
||||
Theme, ThemeMode, ThemeRegistry,
|
||||
input::{InputEvent, InputState},
|
||||
resizable::ResizableState,
|
||||
scroll::ScrollbarHandle,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
@@ -31,6 +32,7 @@ use tokio::runtime::Runtime;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, ConfigStore},
|
||||
session::ssh_config::SshConfigEntry,
|
||||
system::{SystemSampler, SystemSnapshot},
|
||||
terminal::{self, BackendEvent, TabKind, TerminalTab},
|
||||
};
|
||||
@@ -235,6 +237,8 @@ pub(crate) struct Ashell {
|
||||
pub(crate) sync_status: SharedString,
|
||||
pub(crate) sftp_path_input: Entity<InputState>,
|
||||
pub(crate) ssh_auth_method: AuthMethod,
|
||||
pub(crate) ssh_config_entries: Vec<SshConfigEntry>,
|
||||
pub(crate) ssh_config_selected: Option<usize>,
|
||||
pub(crate) editing_session_id: Option<String>,
|
||||
pub(crate) follow_system_theme: bool,
|
||||
pub(crate) theme_mode: ThemeMode,
|
||||
@@ -317,9 +321,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,
|
||||
@@ -387,16 +401,22 @@ impl Ashell {
|
||||
.placeholder("SSH private key passphrase (optional)")
|
||||
.masked(true)
|
||||
});
|
||||
let proxy_host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
|
||||
let proxy_port_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
|
||||
let proxy_user_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
|
||||
let proxy_password_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_password").to_string()).masked(true));
|
||||
let proxy_host_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_host").to_string()));
|
||||
let proxy_port_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_port").to_string()));
|
||||
let proxy_user_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("proxy_user").to_string()));
|
||||
let proxy_password_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("proxy_password").to_string())
|
||||
.masked(true)
|
||||
});
|
||||
let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/"));
|
||||
let sftp_new_folder_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
|
||||
let search_input = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder(t!("search").to_string())
|
||||
});
|
||||
let search_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("search").to_string()));
|
||||
let config = ConfigStore::load().unwrap_or_else(|err| {
|
||||
tracing::warn!("failed to load config: {err:#}");
|
||||
ConfigStore::in_memory()
|
||||
@@ -409,7 +429,12 @@ impl Ashell {
|
||||
let global_proxy_port_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder(t!("proxy_port").to_string())
|
||||
.default_value(config.global_proxy_port().map(|p| p.to_string()).unwrap_or_default())
|
||||
.default_value(
|
||||
config
|
||||
.global_proxy_port()
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
let global_proxy_user_input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
@@ -588,6 +613,8 @@ impl Ashell {
|
||||
sync_status: t!("sync_not_run").into(),
|
||||
sftp_path_input,
|
||||
ssh_auth_method: AuthMethod::Password,
|
||||
ssh_config_entries: crate::session::ssh_config::parse_ssh_config().unwrap_or_default(),
|
||||
ssh_config_selected: None,
|
||||
editing_session_id: None,
|
||||
follow_system_theme,
|
||||
theme_mode,
|
||||
@@ -681,6 +708,9 @@ impl Ashell {
|
||||
events_tx,
|
||||
last_window_size: None,
|
||||
last_sidebar_width,
|
||||
should_move_window: false,
|
||||
hovered_url: None,
|
||||
cmd_ctrl_pressed: false,
|
||||
_subscriptions,
|
||||
};
|
||||
|
||||
@@ -767,7 +797,9 @@ impl Ashell {
|
||||
| crate::session::config::CursorStyle::BeamBlink
|
||||
);
|
||||
let now = std::time::Instant::now();
|
||||
let blink_due = is_blinking && now.duration_since(last_blink_time) >= std::time::Duration::from_millis(600);
|
||||
let blink_due = is_blinking
|
||||
&& now.duration_since(last_blink_time)
|
||||
>= std::time::Duration::from_millis(600);
|
||||
if changed || system_sampled || blink_due {
|
||||
cx.notify();
|
||||
idle_frames = 0;
|
||||
@@ -1221,13 +1253,28 @@ impl Ashell {
|
||||
}
|
||||
}
|
||||
gpui::WindowBounds::Maximized(b) => {
|
||||
let mut restore_bounds = (b.origin.x.into(), b.origin.y.into(), b.size.width.into(), b.size.height.into());
|
||||
let mut restore_bounds = (
|
||||
b.origin.x.into(),
|
||||
b.origin.y.into(),
|
||||
b.size.width.into(),
|
||||
b.size.height.into(),
|
||||
);
|
||||
if let Some(existing_bounds) = config.window_bounds() {
|
||||
match existing_bounds {
|
||||
crate::session::config::SavedWindowBounds::Windowed { x, y, width, height } => {
|
||||
crate::session::config::SavedWindowBounds::Windowed {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
restore_bounds = (*x, *y, *width, *height);
|
||||
}
|
||||
crate::session::config::SavedWindowBounds::Maximized { x, y, width, height } => {
|
||||
crate::session::config::SavedWindowBounds::Maximized {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
restore_bounds = (*x, *y, *width, *height);
|
||||
}
|
||||
_ => {}
|
||||
@@ -1249,13 +1296,15 @@ impl Ashell {
|
||||
}
|
||||
}
|
||||
};
|
||||
let workspace_sizes: Vec<f32> = self.workspace_panels
|
||||
let workspace_sizes: Vec<f32> = self
|
||||
.workspace_panels
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
.map(|s| s.into())
|
||||
.collect();
|
||||
let mut body_sizes: Vec<f32> = self.body_panels
|
||||
let mut body_sizes: Vec<f32> = self
|
||||
.body_panels
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
+35
-7
@@ -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::{
|
||||
@@ -2334,9 +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()
|
||||
.when(is_url_hovered, |d| d.cursor_pointer())
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
@@ -2647,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(
|
||||
@@ -2733,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)
|
||||
@@ -2808,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)),
|
||||
),
|
||||
)
|
||||
|
||||
+162
-83
@@ -9,12 +9,18 @@ use directories::BaseDirs;
|
||||
use russh::{
|
||||
ChannelMsg, Disconnect,
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
keys::{PrivateKey, decode_secret_key, load_secret_key},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, Session},
|
||||
session::{
|
||||
config::{AuthMethod, Session},
|
||||
ssh_keys::{
|
||||
authenticate_with_default_keys, normalize_inline_private_key, private_keys_with_algs,
|
||||
session_has_explicit_key,
|
||||
},
|
||||
},
|
||||
system::{SystemSnapshot, remote_snapshot_from_kv},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx},
|
||||
};
|
||||
@@ -230,12 +236,18 @@ async fn connect_and_authenticate(
|
||||
addr,
|
||||
session.user
|
||||
);
|
||||
let status_text = if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
|
||||
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
|
||||
format!("connecting to {addr} via {} proxy {}:{}", ptype.to_uppercase(), phost, pport_val)
|
||||
} else {
|
||||
format!("opening tcp connection to {addr}")
|
||||
};
|
||||
let status_text =
|
||||
if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
|
||||
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
|
||||
format!(
|
||||
"connecting to {addr} via {} proxy {}:{}",
|
||||
ptype.to_uppercase(),
|
||||
phost,
|
||||
pport_val
|
||||
)
|
||||
} else {
|
||||
format!("opening tcp connection to {addr}")
|
||||
};
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: status_text,
|
||||
@@ -267,7 +279,12 @@ async fn connect_and_authenticate(
|
||||
.context("password authentication failed")?
|
||||
}
|
||||
AuthMethod::Key => {
|
||||
let source = key_source_label(session);
|
||||
let has_explicit_key = session_has_explicit_key(session);
|
||||
let source = if has_explicit_key {
|
||||
key_source_label(session)
|
||||
} else {
|
||||
"~/.ssh/ default keys".to_string()
|
||||
};
|
||||
tracing::info!(
|
||||
"[ssh] sending key authentication for {}@{} (key source: {})",
|
||||
session.user,
|
||||
@@ -276,44 +293,143 @@ async fn connect_and_authenticate(
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
text: if has_explicit_key {
|
||||
format!("connected to {addr}, loading private key from {source}")
|
||||
} else {
|
||||
format!(
|
||||
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
|
||||
session.user
|
||||
)
|
||||
},
|
||||
});
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!("[ssh] public key auth failed with algorithm, trying next");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[ssh] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
|
||||
let success = if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!("[ssh] public key auth failed with algorithm, trying next");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[ssh] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
source,
|
||||
algorithm
|
||||
));
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
source,
|
||||
algorithm
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"public key authentication failed for {}@{}:{} - no valid default key found in ~/.ssh/",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
};
|
||||
success
|
||||
}
|
||||
AuthMethod::Config => {
|
||||
// SSH Config auth: try the identity file from config, or default keys
|
||||
let source = key_source_label(session);
|
||||
tracing::info!(
|
||||
"[ssh] sending ssh-config authentication for {}@{} (key source: {})",
|
||||
session.user,
|
||||
addr,
|
||||
source
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
});
|
||||
|
||||
// If an explicit key path is set from the SSH config IdentityFile, use it;
|
||||
// otherwise try default keys from ~/.ssh/
|
||||
// Note: for Config auth, we never use inline key content
|
||||
let has_explicit_key = !session.private_key_path.trim().is_empty();
|
||||
if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ssh-config key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
source,
|
||||
algorithm
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
|
||||
session.user
|
||||
),
|
||||
});
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ssh-config authentication failed for {}@{}:{} - no valid default key found",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
@@ -335,6 +451,10 @@ async fn connect_and_authenticate(
|
||||
session.port,
|
||||
key_source_label(session)
|
||||
),
|
||||
AuthMethod::Config => format!(
|
||||
"authentication failed: server rejected ssh-config authentication for {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -387,47 +507,6 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
|
||||
+52
-10
@@ -10,6 +10,7 @@ use uuid::Uuid;
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
Key,
|
||||
Config,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -176,6 +177,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)]
|
||||
@@ -288,6 +291,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,
|
||||
@@ -435,7 +439,11 @@ impl ConfigStore {
|
||||
}
|
||||
|
||||
pub fn sync_s3_region(&self) -> &str {
|
||||
if self.cache.sync_s3_region.is_empty() { "us-east-1" } else { &self.cache.sync_s3_region }
|
||||
if self.cache.sync_s3_region.is_empty() {
|
||||
"us-east-1"
|
||||
} else {
|
||||
&self.cache.sync_s3_region
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sync_s3_bucket(&self) -> &str {
|
||||
@@ -443,7 +451,11 @@ impl ConfigStore {
|
||||
}
|
||||
|
||||
pub fn sync_s3_object_key(&self) -> &str {
|
||||
if self.cache.sync_s3_object_key.is_empty() { "ashell-sync.json" } else { &self.cache.sync_s3_object_key }
|
||||
if self.cache.sync_s3_object_key.is_empty() {
|
||||
"ashell-sync.json"
|
||||
} else {
|
||||
&self.cache.sync_s3_object_key
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sync_connection(&mut self, endpoint: String, username: String) {
|
||||
@@ -702,6 +714,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
|
||||
}
|
||||
@@ -755,8 +775,14 @@ impl ConfigStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ProxyStream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static {}
|
||||
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static> ProxyStream for T {}
|
||||
pub trait ProxyStream:
|
||||
tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static
|
||||
{
|
||||
}
|
||||
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + Sync + 'static> ProxyStream
|
||||
for T
|
||||
{
|
||||
}
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -785,7 +811,9 @@ pub async fn connect_proxy(session: &Session) -> Result<Box<dyn ProxyStream>> {
|
||||
session.proxy_user.clone(),
|
||||
session.proxy_password.clone(),
|
||||
)
|
||||
} else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() {
|
||||
} else if config.cache.read_env_proxy
|
||||
&& ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some()
|
||||
{
|
||||
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
|
||||
(
|
||||
env_p.proxy_type.clone(),
|
||||
@@ -803,10 +831,16 @@ pub async fn connect_proxy(session: &Session) -> Result<Box<dyn ProxyStream>> {
|
||||
config.cache.global_proxy_password.clone(),
|
||||
)
|
||||
} else {
|
||||
("none".to_string(), String::new(), None, String::new(), String::new())
|
||||
(
|
||||
"none".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
String::new(),
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) {
|
||||
let addr = format!("{}:{}", target_host, target_port);
|
||||
let stream = tokio::net::TcpStream::connect(&addr).await?;
|
||||
@@ -889,7 +923,9 @@ pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)>
|
||||
session.proxy_user.clone(),
|
||||
session.proxy_password.clone(),
|
||||
)
|
||||
} else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() {
|
||||
} else if config.cache.read_env_proxy
|
||||
&& ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some()
|
||||
{
|
||||
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
|
||||
(
|
||||
env_p.proxy_type.clone(),
|
||||
@@ -907,10 +943,16 @@ pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)>
|
||||
config.cache.global_proxy_password.clone(),
|
||||
)
|
||||
} else {
|
||||
("none".to_string(), String::new(), None, String::new(), String::new())
|
||||
(
|
||||
"none".to_string(),
|
||||
String::new(),
|
||||
None,
|
||||
String::new(),
|
||||
String::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if proxy_type != "none" && !proxy_host.is_empty() && proxy_port.is_some() {
|
||||
Some((proxy_type, proxy_host, proxy_port))
|
||||
} else {
|
||||
|
||||
+103
-21
@@ -1,8 +1,10 @@
|
||||
pub mod config;
|
||||
pub mod ssh_config;
|
||||
pub mod ssh_keys;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, SharedString, Window, px,
|
||||
AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
SharedString, Window, px,
|
||||
};
|
||||
use gpui_component::{Theme, WindowExt as _, input::InputState};
|
||||
use rust_i18n::t;
|
||||
@@ -12,9 +14,7 @@ use self::config::{AuthMethod, Session};
|
||||
|
||||
use crate::{
|
||||
Ashell, PaneLayout, SelectorEntry, TabGroup,
|
||||
app::constants::{
|
||||
DEFAULT_COLS, DEFAULT_ROWS,
|
||||
},
|
||||
app::constants::{DEFAULT_COLS, DEFAULT_ROWS},
|
||||
backend::{local, ssh},
|
||||
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
|
||||
};
|
||||
@@ -103,13 +103,16 @@ impl Ashell {
|
||||
let mut session = match self.ssh_auth_method {
|
||||
AuthMethod::Password => Session::password(host, port, user, password),
|
||||
AuthMethod::Key => {
|
||||
if key_path.is_empty() && key_inline.trim().is_empty() {
|
||||
self.status = "private key path or content is required".into();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
Session::key(host, port, user, key_path, key_inline, passphrase)
|
||||
}
|
||||
AuthMethod::Config => {
|
||||
// Force key_inline to empty — config mode never uses inline key content.
|
||||
// The backend will try default keys from ~/.ssh/ if no explicit key path is set.
|
||||
let mut session =
|
||||
Session::key(host, port, user, key_path, String::new(), String::new());
|
||||
session.auth = AuthMethod::Config;
|
||||
session
|
||||
}
|
||||
};
|
||||
session.name = name;
|
||||
if let Some(id) = existing_id {
|
||||
@@ -151,6 +154,7 @@ impl Ashell {
|
||||
pub(crate) fn reset_ssh_form(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editing_session_id = None;
|
||||
self.ssh_auth_method = AuthMethod::Password;
|
||||
self.ssh_config_selected = None;
|
||||
Self::set_input_value(&self.session_name_input, "", window, cx);
|
||||
Self::set_input_value(&self.host_input, "", window, cx);
|
||||
Self::set_input_value(&self.port_input, "22", window, cx);
|
||||
@@ -202,15 +206,33 @@ impl Ashell {
|
||||
} 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_host_input,
|
||||
session.proxy_host.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.proxy_port_input,
|
||||
session.proxy_port.map(|p| p.to_string()).unwrap_or_default(),
|
||||
session
|
||||
.proxy_port
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_default(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.proxy_user_input,
|
||||
session.proxy_user.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(
|
||||
&self.proxy_password_input,
|
||||
session.proxy_password.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(&self.proxy_user_input, session.proxy_user.clone(), window, cx);
|
||||
Self::set_input_value(&self.proxy_password_input, session.proxy_password.clone(), window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -354,17 +376,65 @@ 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();
|
||||
}
|
||||
|
||||
pub(crate) fn set_ssh_auth_method(&mut self, method: AuthMethod, cx: &mut Context<Self>) {
|
||||
self.ssh_auth_method = method;
|
||||
if method == AuthMethod::Config {
|
||||
self.refresh_ssh_config();
|
||||
self.ssh_config_selected = None;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_ssh_config(&mut self) {
|
||||
self.ssh_config_entries =
|
||||
crate::session::ssh_config::parse_ssh_config().unwrap_or_default();
|
||||
}
|
||||
|
||||
pub(crate) fn select_ssh_config_entry(
|
||||
&mut self,
|
||||
index: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.ssh_config_selected = Some(index);
|
||||
if let Some(entry) = self.ssh_config_entries.get(index) {
|
||||
Self::set_input_value(
|
||||
&self.session_name_input,
|
||||
entry.host_alias.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(&self.host_input, entry.hostname.clone(), window, cx);
|
||||
Self::set_input_value(&self.port_input, entry.port.to_string(), window, cx);
|
||||
// If no user specified in config, use current system user
|
||||
let user = if entry.user.is_empty() {
|
||||
std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "root".to_string())
|
||||
} else {
|
||||
entry.user.clone()
|
||||
};
|
||||
Self::set_input_value(&self.user_input, user, window, cx);
|
||||
Self::set_input_value(
|
||||
&self.key_path_input,
|
||||
entry.identity_files.first().cloned().unwrap_or_default(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
Self::set_input_value(&self.password_input, String::new(), window, cx);
|
||||
Self::set_input_value(&self.key_inline_input, String::new(), window, cx);
|
||||
Self::set_input_value(&self.passphrase_input, String::new(), window, cx);
|
||||
// Auto-connect on selection
|
||||
self.connect_ssh(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_ssh_proxy_type(&mut self, proxy_type: String, cx: &mut Context<Self>) {
|
||||
self.ssh_proxy_type = proxy_type;
|
||||
cx.notify();
|
||||
@@ -898,12 +968,28 @@ 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() {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
||||
if let Some(active_id) = &self.active_tab {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| &tab.id == active_id) {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| &tab.id == active_id)
|
||||
{
|
||||
tab.clear_selection();
|
||||
}
|
||||
}
|
||||
@@ -958,10 +1044,6 @@ impl Ashell {
|
||||
format!("{}@{}:{}", session.user, session.host, session.port)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
|
||||
tracing::info!(
|
||||
"[split] direction={} pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
|
||||
/// A parsed entry from ~/.ssh/config
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SshConfigEntry {
|
||||
/// The Host pattern (alias) from the config, e.g. "myserver"
|
||||
pub host_alias: String,
|
||||
/// The actual hostname (HostName), defaults to the host alias if not specified
|
||||
pub hostname: String,
|
||||
/// The user, defaults to empty (will use current OS user)
|
||||
pub user: String,
|
||||
/// The port, defaults to 22
|
||||
pub port: u16,
|
||||
/// Identity files specified for this host
|
||||
pub identity_files: Vec<String>,
|
||||
/// Whether this is a wildcard/pattern host (Host * or Host *)
|
||||
pub is_wildcard: bool,
|
||||
}
|
||||
|
||||
/// Parse ~/.ssh/config and return a list of concrete host entries.
|
||||
/// Wildcard patterns (Host *) are excluded.
|
||||
pub fn parse_ssh_config() -> Result<Vec<SshConfigEntry>> {
|
||||
let config_path = ssh_config_path()?;
|
||||
if !config_path.is_file() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)
|
||||
.with_context(|| format!("failed to read {}", config_path.display()))?;
|
||||
|
||||
parse_ssh_config_content(&content)
|
||||
}
|
||||
|
||||
fn ssh_config_path() -> Result<PathBuf> {
|
||||
BaseDirs::new()
|
||||
.map(|dirs| dirs.home_dir().join(".ssh/config"))
|
||||
.context("failed to determine home directory")
|
||||
}
|
||||
|
||||
/// Parse the content of an ssh config file into entries.
|
||||
pub fn parse_ssh_config_content(content: &str) -> Result<Vec<SshConfigEntry>> {
|
||||
let mut entries: Vec<SshConfigEntry> = Vec::new();
|
||||
let mut current_host: Option<SshConfigEntry> = None;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into keyword and value
|
||||
// OpenSSH supports both "keyword value" and "keyword=value" formats
|
||||
let (keyword, value) = if let Some(pos) = line.find('=') {
|
||||
(&line[..pos], line[pos + 1..].trim())
|
||||
} else if let Some(pos) = line.find(' ') {
|
||||
(&line[..pos], line[pos..].trim())
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let keyword_lower = keyword.trim().to_lowercase();
|
||||
let value = value.trim();
|
||||
|
||||
match keyword_lower.as_str() {
|
||||
"host" => {
|
||||
// Save previous entry if it exists and is not a wildcard
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Host line may contain multiple patterns (Host a b c)
|
||||
// Take the first non-wildcard pattern as the display alias
|
||||
let patterns: Vec<&str> = value.split_whitespace().collect();
|
||||
if patterns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let host_alias = patterns
|
||||
.iter()
|
||||
.find(|p| !p.contains('*') && !p.contains('?'))
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| patterns[0].to_string());
|
||||
|
||||
let is_wildcard = value.contains('*') || value.contains('?');
|
||||
|
||||
current_host = Some(SshConfigEntry {
|
||||
host_alias: host_alias.clone(),
|
||||
hostname: host_alias,
|
||||
user: String::new(),
|
||||
port: 22,
|
||||
identity_files: Vec::new(),
|
||||
is_wildcard,
|
||||
});
|
||||
}
|
||||
"hostname" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.hostname = value.to_string();
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.user = value.to_string();
|
||||
}
|
||||
}
|
||||
"port" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.port = value.parse::<u16>().unwrap_or(22);
|
||||
}
|
||||
}
|
||||
"identityfile" => {
|
||||
if let Some(entry) = current_host.as_mut() {
|
||||
entry.identity_files.push(value.to_string());
|
||||
}
|
||||
}
|
||||
// Skip Match blocks and Include directives — not supported yet
|
||||
"match" | "include" => {
|
||||
// If we encounter a Match block, flush the current Host entry
|
||||
// since Match blocks apply to all subsequent hosts until the next Match/Host
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
// Don't create a new entry for Match/Include
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Save the last entry if it's not a wildcard
|
||||
if let Some(entry) = current_host.take() {
|
||||
if !entry.is_wildcard {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_ssh_config_content() {
|
||||
let content = r#"
|
||||
Host myhost
|
||||
HostName 1.2.3.4
|
||||
User git
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
|
||||
Host
|
||||
Host =
|
||||
|
||||
Host anotherhost
|
||||
HostName 5.6.7.8
|
||||
"#;
|
||||
|
||||
let entries = parse_ssh_config_content(content).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
|
||||
assert_eq!(entries[0].host_alias, "myhost");
|
||||
assert_eq!(entries[0].hostname, "1.2.3.4");
|
||||
assert_eq!(entries[0].user, "git");
|
||||
assert_eq!(entries[0].port, 2222);
|
||||
assert_eq!(entries[0].identity_files, vec!["~/.ssh/id_rsa".to_string()]);
|
||||
|
||||
assert_eq!(entries[1].host_alias, "anotherhost");
|
||||
assert_eq!(entries[1].hostname, "5.6.7.8");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use directories::BaseDirs;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
};
|
||||
|
||||
use crate::session::config::Session;
|
||||
|
||||
pub const DEFAULT_KEY_NAMES: &[&str] = &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
|
||||
|
||||
pub fn session_has_explicit_key(session: &Session) -> bool {
|
||||
!session.private_key_path.trim().is_empty()
|
||||
|| !normalize_inline_private_key(&session.private_key_inline).is_empty()
|
||||
}
|
||||
|
||||
pub fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
pub async fn authenticate_with_default_keys<H>(
|
||||
handle: &mut client::Handle<H>,
|
||||
user: &str,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<bool>
|
||||
where
|
||||
H: Handler + Send + Sync,
|
||||
H::Error: Into<anyhow::Error>,
|
||||
{
|
||||
let Some(ssh_dir) = BaseDirs::new().map(|d| d.home_dir().join(".ssh")) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
for key_name in DEFAULT_KEY_NAMES {
|
||||
let key_path = ssh_dir.join(key_name);
|
||||
if !key_path.exists() {
|
||||
continue;
|
||||
}
|
||||
tracing::debug!("[ssh] trying default key {}", key_path.display());
|
||||
match load_secret_key(&key_path, passphrase) {
|
||||
Ok(keypair) => {
|
||||
if let Ok(keys) = private_keys_with_algs(keypair) {
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(user, key).await {
|
||||
Ok(true) => return Ok(true),
|
||||
Ok(false) | Err(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
+108
-70
@@ -14,7 +14,7 @@ use flate2::read::GzDecoder;
|
||||
use russh::{
|
||||
Disconnect,
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
keys::{PrivateKey, decode_secret_key, load_secret_key},
|
||||
};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use tokio::{
|
||||
@@ -29,7 +29,13 @@ use zip::read::ZipArchive;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, Session},
|
||||
session::{
|
||||
config::{AuthMethod, Session},
|
||||
ssh_keys::{
|
||||
authenticate_with_default_keys, normalize_inline_private_key, private_keys_with_algs,
|
||||
session_has_explicit_key,
|
||||
},
|
||||
},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
@@ -844,37 +850,109 @@ async fn connect_and_authenticate(
|
||||
.await
|
||||
.context("password authentication failed")?,
|
||||
AuthMethod::Key => {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"[sftp] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
let has_explicit_key = session_has_explicit_key(session);
|
||||
let success = if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"[sftp] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"public key authentication failed for {}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"public key authentication failed for {}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"public key authentication failed for {}@{}:{} - no valid default key found in ~/.ssh/",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
};
|
||||
success
|
||||
}
|
||||
AuthMethod::Config => {
|
||||
// For Config auth, try the identity file from config entry, or default keys
|
||||
// Note: for Config auth, we never use inline key content
|
||||
let has_explicit_key = !session.private_key_path.trim().is_empty();
|
||||
|
||||
if has_explicit_key {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!(
|
||||
"[sftp] public key auth failed with algorithm, trying next"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[sftp] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"ssh-config key authentication failed for {}@{}:{}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
} else {
|
||||
let passphrase = session.passphrase.trim();
|
||||
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
|
||||
let success =
|
||||
authenticate_with_default_keys(&mut handle, &session.user, passphrase)
|
||||
.await?;
|
||||
if !success {
|
||||
return Err(anyhow!(
|
||||
"ssh-config authentication failed for {}@{}:{} - no valid default key found",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
success
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
@@ -886,6 +964,7 @@ async fn connect_and_authenticate(
|
||||
match session.auth {
|
||||
AuthMethod::Password => "password",
|
||||
AuthMethod::Key => "public key",
|
||||
AuthMethod::Config => "ssh-config",
|
||||
},
|
||||
session.user,
|
||||
session.host,
|
||||
@@ -927,47 +1006,6 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
|
||||
+72
-3
@@ -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,
|
||||
@@ -143,6 +163,7 @@ pub struct PrepaintState {
|
||||
runs: Vec<BatchedTextRun>,
|
||||
custom_blocks: Vec<LayoutCustomBlock>,
|
||||
cursor: Option<CursorLayout>,
|
||||
underlines: Vec<LayoutUnderline>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -354,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
|
||||
@@ -411,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);
|
||||
|
||||
@@ -463,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> {
|
||||
@@ -543,7 +587,7 @@ 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.
|
||||
@@ -580,6 +624,7 @@ impl Element for TerminalElement {
|
||||
runs,
|
||||
custom_blocks,
|
||||
cursor: self.cursor_layout(cx),
|
||||
underlines,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +652,10 @@ impl Element for TerminalElement {
|
||||
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 = draw_origin.x.as_f32()
|
||||
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
|
||||
@@ -746,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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+26
-1
@@ -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
|
||||
@@ -379,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;
|
||||
}
|
||||
@@ -454,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)> {
|
||||
|
||||
Reference in New Issue
Block a user