Compare commits

..
17 Commits
Author SHA1 Message Date
TomZz 84eccb5521 bump version to 0.4.11 2026-07-15 03:24:39 +08:00
TomZz 4da9949c84 perf: avoid reading and decrypting config file from disk repeatedly on every render frame 2026-07-15 03:23:36 +08:00
TomZz 85fb354f80 fix: add a 16-second connection timeout to tcp and proxy connections 2026-07-15 03:23:36 +08:00
TomZz 584f962298 feat: implement manual path sync from terminal to file manager (fixes #83) 2026-07-15 03:23:36 +08:00
TomZz a32fd9437d style: run cargo fmt 2026-07-15 02:26:16 +08:00
TomZz 4c292c060c fix: clear connection_progress on tab close to resolve stuck modal (fixes #82) 2026-07-15 02:20:08 +08:00
TomZz c0a954ea0c feat: implement hardware-encrypted configuration file 2026-07-15 02:20:06 +08:00
TomZz ab41ccbd6b bump version to 0.4.10 2026-07-08 19:55:29 +08:00
TomZz 43717e4378 fix(ssh): improve ssh config parser robustness and revert gitignore changes 2026-07-08 19:51:40 +08:00
tg.shi 31218f7a37 feat(ssh): connect via ~/.ssh/config entries
Add AuthMethod::Config: parse ~/.ssh/config and list concrete host
entries in the connection dialog. Selecting an entry pre-fills
host/port/user/identity file and connects.

- Extract shared key utilities into session::ssh_keys
  (private_keys_with_algs, normalize_inline_private_key,
  authenticate_with_default_keys, session_has_explicit_key), reused by
  backend::ssh and sftp to remove duplication.
- Add session::ssh_config parser for ~/.ssh/config (Host/HostName/User/
  Port/IdentityFile), skipping wildcard patterns and Match/Include.
- Key auth now falls back to default keys (~/.ssh/id_*) when no explicit
  key path or inline content is provided.
- Preserve escaped \r\n normalization in normalize_inline_private_key
  when refactoring into the shared module.
2026-07-08 11:50:51 +08:00
TomZz af5c2831f6 fix: remove redundant window drag area to fix window control overlay (resolves #73) 2026-07-04 12:11:42 +08:00
TomZz a8669738e6 fix: relocate window title bar dragging to tab bar container (resolves #72) 2026-07-03 19:55:22 +08:00
RabitLogic 2dad1fa5a1 fix: enable window title bar dragging on Linux via manual mouse tracking and deb packaging (#68) 2026-07-03 16:51:18 +08:00
TomZz e2bf76f7b2 bump version to v0.4.9 2026-07-03 04:56:05 +08:00
TomZz a654695d7b feat: support terminal URL highlighting on hover, click-to-open and multi-line wrapping 2026-07-03 04:36:42 +08:00
TomZz cb078ef572 feat: add setting to lock window layout (resolves #69) 2026-07-02 23:39:03 +08:00
TomZz c0aa21a994 fix: enable window title bar dragging in integrated mode (resolves #67) 2026-07-02 23:15:07 +08:00
28 changed files with 3756 additions and 1066 deletions
Generated
+1 -1
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.8"
version = "0.4.11"
dependencies = [
"alacritty_terminal",
"anyhow",
+13 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.8"
version = "0.4.11"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -54,6 +54,18 @@ rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
[package.metadata.deb]
maintainer = "ashell contributors"
license-file = ["LICENSE"]
extended-description = "A GPUI Component based SSH and local terminal client"
section = "terminal"
priority = "optional"
assets = [
["target/release/ashell", "usr/bin/", "755"],
["assets/ashell.desktop", "usr/share/applications/", "644"],
["assets/icons/ashell.png", "usr/share/icons/hicolor/256x256/apps/", "644"],
]
[profile.release]
opt-level = 3
lto = "thin"
+33
View File
@@ -82,6 +82,39 @@ open target/release/ashell.app
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
## Package Linux (Debian/Ubuntu)
### Prerequisites
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### Build .deb
```bash
cargo build --release
cargo deb
```
The `.deb` file will be generated at:
```
target/debian/ashell_0.4.8-1_amd64.deb
```
### Install
```bash
sudo dpkg -i target/debian/ashell_0.4.8-1_amd64.deb
```
After installation, you can launch from the application menu or by running `ashell` in the terminal. The `.deb` package includes:
- `/usr/bin/ashell` — Main binary
- `/usr/share/applications/ashell.desktop` — Desktop entry
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — Application icon
## License
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
+33
View File
@@ -82,6 +82,39 @@ open target/release/ashell.app
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## 打包 Linux (Debian/Ubuntu)
### 前置条件
```bash
sudo apt install pkg-config libfontconfig1-dev
cargo install cargo-deb
```
### 构建 .deb 包
```bash
cargo build --release
cargo deb
```
生成的 `.deb` 文件位于:
```
target/debian/ashell_0.4.9-1_amd64.deb
```
### 安装
```bash
sudo dpkg -i target/debian/ashell_0.4.9-1_amd64.deb
```
安装后可通过应用菜单或命令行 `ashell` 启动。`.deb` 包包含以下内容:
- `/usr/bin/ashell` — 主程序
- `/usr/share/applications/ashell.desktop` — 桌面入口
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — 应用图标
## 协议
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Ashell
Comment=A GPUI Component based SSH and local terminal client
Exec=ashell
Icon=ashell
Terminal=false
Type=Application
Categories=System;TerminalEmulator;Utility;
StartupWMClass=ashell
+6
View File
@@ -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,11 +127,15 @@ 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"
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
sync_cwd_tooltip: "Sync path from active terminal"
sync_cwd: "Sync Path"
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
scroll: "Scroll ↓"
clone: "Clone"
+6
View File
@@ -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,11 +128,15 @@ software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
keyword_highlight: "终端关键词高亮"
lock_layout: "锁定窗口布局"
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
sync_cwd_tooltip: "从当前终端同步路径"
sync_cwd: "同步路径"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
scroll: "滑动查看 ↓"
clone: "克隆"
+595 -443
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -128,12 +128,20 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
WorkspaceAction {
id: "Copy",
label_key: "settings_copy",
default_suffix: if cfg!(target_os = "macos") { "c" } else { "shift-c" },
default_suffix: if cfg!(target_os = "macos") {
"c"
} else {
"shift-c"
},
},
WorkspaceAction {
id: "Paste",
label_key: "settings_paste",
default_suffix: if cfg!(target_os = "macos") { "v" } else { "shift-v" },
default_suffix: if cfg!(target_os = "macos") {
"v"
} else {
"shift-v"
},
},
];
@@ -305,7 +313,15 @@ impl KeybindingsPage {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch", "Copy", "Paste"],
vec![
"OpenSettings",
"OpenSession",
"OpenTransfers",
"NewSsh",
"OpenSearch",
"Copy",
"Paste",
],
),
(
"settings_group_keybind_zoom",
+132 -16
View File
@@ -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;
@@ -978,7 +1010,7 @@ impl Ashell {
}
BackendEvent::TerminalTitleChanged { tab_id, title } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.title = title.clone();
tab.dynamic_title = title;
}
}
BackendEvent::SyncFinished(result) => {
@@ -1196,6 +1228,73 @@ impl Ashell {
cx.notify();
}
pub(crate) fn sync_cwd_from_terminal(
&mut self,
_window: &mut gpui::Window,
_cx: &mut Context<Self>,
) {
let active_id = self.active_tab.clone();
let Some(active_id) = active_id else {
return;
};
if let Some(tab) = self.tabs.iter().find(|t| t.id == active_id) {
let home_dir = if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&tab.id))
{
group
.sftp
.as_ref()
.map(|s| s.home_dir.as_str())
.unwrap_or("/")
} else {
"/"
};
let parsed = Self::parse_path_from_title(&tab.dynamic_title, home_dir);
if let Some(path) = parsed {
if let Some(group) = self
.tab_groups
.iter_mut()
.find(|g| g.pane_root.contains(&active_id))
{
if let Some(sftp) = group.sftp.as_mut() {
sftp.current_path = path.clone();
self.pending_sftp_path_sync = Some(path.clone());
if let Some(handle) = self.sftp_handles.get(&group.id) {
let _ = handle
.commands
.send(crate::sftp::SftpCommand::ListDir(path));
}
}
}
}
}
}
fn parse_path_from_title(title: &str, home_dir: &str) -> Option<String> {
let title = title.strip_prefix("ASHELL_CWD:").unwrap_or(title);
let path_part = if let Some(pos) = title.find(':') {
title[pos + 1..].trim()
} else {
title.trim()
};
if path_part.starts_with('/') {
Some(path_part.to_string())
} else if path_part == "~" {
Some(home_dir.to_string())
} else if let Some(rest) = path_part.strip_prefix("~/") {
let home = home_dir.trim_end_matches('/');
Some(format!("{}/{}", home, rest))
} else {
None
}
}
pub(crate) fn save_layout_state(&self, window: &mut gpui::Window, cx: &gpui::App) {
if self.is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
@@ -1221,13 +1320,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 +1363,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()
+329
View File
@@ -0,0 +1,329 @@
#![allow(dead_code)]
use std::ops::Range;
use gpui::{
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
};
mod panel;
mod resize_handle;
pub use panel::*;
pub(crate) use resize_handle::*;
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
pub enum ResizablePanelEvent {
Resized,
}
/// Create a [`ResizablePanelGroup`] with horizontal resizing
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
}
/// Create a [`ResizablePanelGroup`] with vertical resizing
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
ResizablePanelGroup::new(id).axis(Axis::Vertical)
}
/// Create a [`ResizablePanel`].
pub fn resizable_panel() -> ResizablePanel {
ResizablePanel::new()
}
/// State for a [`ResizablePanel`]
#[derive(Debug, Clone)]
pub struct ResizableState {
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
axis: Axis,
pub(crate) panels: Vec<ResizablePanelState>,
pub(crate) sizes: Vec<Pixels>,
pub(crate) resizing_panel_ix: Option<usize>,
pub(crate) bounds: Bounds<Pixels>,
}
impl Default for ResizableState {
fn default() -> Self {
Self {
axis: Axis::Horizontal,
panels: vec![],
sizes: vec![],
resizing_panel_ix: None,
bounds: Bounds::default(),
}
}
}
impl ResizableState {
/// Get the size of the panels.
pub fn sizes(&self) -> &Vec<Pixels> {
&self.sizes
}
/// Programmatically resize the panel at `ix` to `size`, redistributing
/// space among siblings using the same logic as a drag.
///
/// Sizes are clamped to the panel's `size_range` and to the container.
/// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference
/// persistence) see the change just as if the user had dragged a handle.
///
/// Out-of-range indices are a no-op. For the last panel, space is taken
/// from the previous sibling (the last panel has no handle of its own).
pub fn resize_panel(
&mut self,
ix: usize,
size: Pixels,
window: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
if ix + 1 < self.sizes.len() {
self.resize_panel_at_handle(ix, size, window, cx);
} else if ix > 0 {
// Last panel: drive its size by resizing the previous sibling so
// the freed space lands here.
let delta = self.sizes[ix] - size;
let prev = self.sizes[ix - 1];
self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
}
self.done_resizing(cx);
}
pub(crate) fn insert_panel(
&mut self,
size: Option<Pixels>,
ix: Option<usize>,
cx: &mut Context<Self>,
) {
let panel_state = ResizablePanelState {
size,
..Default::default()
};
let size = size.unwrap_or(PANEL_MIN_SIZE);
// We make sure that the size always sums up to the container size
// by reducing the size of all other panels first.
let container_size = self.container_size().max(px(1.));
let total_leftover_size = (container_size - size).max(px(1.));
for (i, panel) in self.panels.iter_mut().enumerate() {
let ratio = self.sizes[i] / container_size;
self.sizes[i] = total_leftover_size * ratio;
panel.size = Some(self.sizes[i]);
}
if let Some(ix) = ix {
self.panels.insert(ix, panel_state);
self.sizes.insert(ix, size);
} else {
self.panels.push(panel_state);
self.sizes.push(size);
};
cx.notify();
}
pub(crate) fn sync_panels_count(
&mut self,
axis: Axis,
panels_count: usize,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
self.axis = axis;
if panels_count > self.panels.len() {
let diff = panels_count - self.panels.len();
self.panels
.extend(vec![ResizablePanelState::default(); diff]);
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
changed = true;
}
if panels_count < self.panels.len() {
self.panels.truncate(panels_count);
self.sizes.truncate(panels_count);
changed = true;
}
if changed {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
}
pub(crate) fn update_panel_size(
&mut self,
panel_ix: usize,
bounds: Bounds<Pixels>,
size_range: Range<Pixels>,
cx: &mut Context<Self>,
) {
let size = bounds.size.along(self.axis);
// This check is only necessary to stop the very first panel from resizing on its own
// it needs to be passed when the panel is freshly created so we get the initial size,
// but its also fine when it sometimes passes later.
if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
self.sizes[panel_ix] = size;
self.panels[panel_ix].size = Some(size);
}
self.panels[panel_ix].bounds = bounds;
self.panels[panel_ix].size_range = size_range;
cx.notify();
}
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
self.panels.remove(panel_ix);
self.sizes.remove(panel_ix);
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
if resizing_panel_ix > panel_ix {
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
}
}
self.adjust_to_container_size(cx);
}
pub(crate) fn replace_panel(
&mut self,
panel_ix: usize,
panel: ResizablePanelState,
cx: &mut Context<Self>,
) {
let old_size = self.sizes[panel_ix];
self.panels[panel_ix] = panel;
self.sizes[panel_ix] = old_size;
self.adjust_to_container_size(cx);
}
pub(crate) fn clear(&mut self) {
self.panels.clear();
self.sizes.clear();
}
#[inline]
pub(crate) fn container_size(&self) -> Pixels {
self.bounds.size.along(self.axis)
}
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
self.resizing_panel_ix = None;
cx.emit(ResizablePanelEvent::Resized);
}
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
let Some(panel) = self.panels.get(ix) else {
return PANEL_MIN_SIZE..Pixels::MAX;
};
panel.size_range.clone()
}
fn sync_real_panel_sizes(&mut self, _: &App) {
for (i, panel) in self.panels.iter().enumerate() {
self.sizes[i] = panel.bounds.size.along(self.axis);
}
}
pub(crate) fn resize_panel_at_handle(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
let old_sizes = self.sizes.clone();
let mut ix = ix;
if ix >= old_sizes.len() - 1 {
return;
}
let container_size = self.container_size();
self.sync_real_panel_sizes(cx);
let move_changed = size - old_sizes[ix];
if move_changed == px(0.) {
return;
}
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
let is_expand = move_changed > px(0.);
let main_ix = ix;
let mut new_sizes = old_sizes.clone();
if is_expand {
let mut changed = new_size - old_sizes[ix];
new_sizes[ix] = new_size;
while changed > px(0.) && ix < old_sizes.len() - 1 {
ix += 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
new_sizes[ix] -= to_reduce;
changed -= to_reduce;
}
} else {
let mut changed = new_size - size;
new_sizes[ix] = new_size;
while changed > px(0.) && ix > 0 {
ix -= 1;
let size_range = self.panel_size_range(ix);
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
let to_reduce = changed.min(available_size);
changed -= to_reduce;
new_sizes[ix] -= to_reduce;
}
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
}
let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
if total_size > container_size {
let overflow = total_size - container_size;
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
}
for (i, _) in old_sizes.iter().enumerate() {
let size = new_sizes[i];
self.panels[i].size = Some(size);
}
self.sizes = new_sizes;
cx.notify();
}
pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
}
let container_size = self.container_size();
let total_size = px(self.sizes.iter().map(|s| s.as_f32()).sum::<f32>());
for i in 0..self.panels.len() {
let size = self.sizes[i];
let ratio = size / total_size;
let new_size = container_size * ratio;
self.sizes[i] = new_size;
self.panels[i].size = Some(new_size);
}
cx.notify();
}
}
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResizablePanelState {
pub size: Option<Pixels>,
pub size_range: Range<Pixels>,
pub(crate) bounds: Bounds<Pixels>,
}
+425
View File
@@ -0,0 +1,425 @@
use std::{
ops::{Deref, Range},
rc::Rc,
};
use gpui::{
Along, AnyElement, App, AppContext as _, Axis, Bounds, Context, Element, ElementId, Empty,
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled,
Window, div, prelude::FluentBuilder,
};
use gpui_component::{AxisExt, ElementExt, StyledExt, h_flex, v_flex};
use super::{PANEL_MIN_SIZE, ResizablePanelEvent, ResizableState, resizable_panel, resize_handle};
#[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);
}
}
})
}
}
+221
View File
@@ -0,0 +1,221 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
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)
});
}
}
+33 -31
View File
@@ -1,13 +1,14 @@
use std::collections::HashMap;
use gpui::{
Context, Focusable as _, Hsla, IntoElement, InteractiveElement as _, MouseButton,
Context, Focusable as _, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex, input::Input,
h_flex,
input::Input,
};
use rust_i18n::t;
@@ -214,7 +215,7 @@ impl Ashell {
};
if let Some(tab) = tab {
let snapshot = tab.render_snapshot();
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
@@ -274,7 +275,7 @@ impl Ashell {
let Some(tab) = tab else {
return None;
};
let snapshot = tab.render_snapshot();
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
@@ -323,23 +324,19 @@ impl Ashell {
}
/// Render the search button (used in the tab bar).
pub(crate) fn render_search_button(
&self,
cx: &mut Context<Self>,
) -> impl gpui::IntoElement {
pub(crate) fn render_search_button(&self, cx: &mut Context<Self>) -> impl gpui::IntoElement {
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
div()
.child(
Button::new("search-btn")
.ghost()
.small()
.rounded(px(999.))
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_search(window, cx);
})),
)
div().child(
Button::new("search-btn")
.ghost()
.small()
.rounded(px(999.))
.icon(IconName::Search)
.tooltip(t!("search").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_search(window, cx);
})),
)
}
/// Render the expanded search bar overlay (when search is active).
@@ -369,10 +366,13 @@ impl Ashell {
this.search_bar_bounds = Some(bounds);
});
})
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| {
this.refocus_search_input(window, cx);
cx.stop_propagation();
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
this.refocus_search_input(window, cx);
cx.stop_propagation();
}),
)
.child(
h_flex()
.gap_1()
@@ -385,13 +385,15 @@ impl Ashell {
.child(
div()
.w(px(200.))
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
if event.keystroke.key.as_str() == "escape" {
this.close_search(window, cx);
window.prevent_default();
cx.stop_propagation();
}
}))
.on_key_down(cx.listener(
|this, event: &gpui::KeyDownEvent, window, cx| {
if event.keystroke.key.as_str() == "escape" {
this.close_search(window, cx);
window.prevent_default();
cx.stop_propagation();
}
},
))
.child(Input::new(&self.search_input).small()),
)
.when(!current_display.is_empty(), |this| {
+11 -4
View File
@@ -179,7 +179,14 @@ pub(crate) fn sync_macos_launch_environment() {
}
fn read_proxy_from_env() -> Option<(String, String, Option<u16>, String, String)> {
let vars = ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
let vars = [
"ALL_PROXY",
"all_proxy",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
];
for var in vars {
if let Ok(val) = std::env::var(var) {
if val.is_empty() {
@@ -301,13 +308,13 @@ pub(crate) fn open_main_window(cx: &mut App) {
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
let handle = window.window_handle();
if !cx.windows().contains(&handle) {
tracing::warn!("[ui] window not found in app during close, skipping save layout state.");
tracing::warn!(
"[ui] window not found in app during close, skipping save layout state."
);
return true;
}
view_clone.read(cx).save_layout_state(window, cx);
+4 -1
View File
@@ -17,7 +17,10 @@ use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let has_system_maple = cx.text_system().all_font_names().contains(&"Maple Mono NF CN".to_string());
let has_system_maple = cx
.text_system()
.all_font_names()
.contains(&"Maple Mono NF CN".to_string());
if has_system_maple {
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
} else {
+118 -57
View File
@@ -1,3 +1,4 @@
use crate::app::resizable::{h_resizable, resizable_panel, v_resizable};
use gpui::{
Context, ElementId, Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement,
MouseButton, MouseDownEvent, ParentElement as _, PathBuilder, Pixels, Render,
@@ -13,7 +14,6 @@ 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,
@@ -26,7 +26,7 @@ use crate::{
sftp::format_mtime,
sftp::ops::is_editable_text_file,
system::format_bytes,
terminal::{self, TabKind, TerminalTab},
terminal::{self, TabKind},
};
impl Ashell {
@@ -197,6 +197,17 @@ impl Ashell {
.when_some(active_sftp.clone(), |this, sftp| {
let selected_entries = sftp.selected_entries.clone();
this.child(
Button::new("sftp-sync-cwd")
.ghost()
.small()
.icon(IconName::SquareTerminal)
.label(t!("sync_cwd").to_string())
.tooltip(t!("sync_cwd_tooltip").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.sync_cwd_from_terminal(window, cx);
})),
)
.child(
Button::new("sftp-refresh")
.ghost()
.small()
@@ -1984,7 +1995,7 @@ impl Ashell {
) -> impl IntoElement {
let is_macos = cfg!(target_os = "macos");
let is_fullscreen = window.is_fullscreen();
let is_active = cx.active_window() == Some(window.window_handle());
h_flex()
@@ -1999,9 +2010,17 @@ impl Ashell {
.id("window-close")
.size(px(12.))
.rounded_full()
.bg(if is_active { hsla(3.0 / 360.0, 1.0, 0.67, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Red or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(3.0 / 360.0, 1.0, 0.67, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Close))
.bg(if is_active {
hsla(3.0 / 360.0, 1.0, 0.67, 1.0)
} else {
hsla(0.0, 0.0, 0.8, 1.0)
}) // Red or Inactive Grey
.group_hover("window-controls", |s| {
s.bg(hsla(3.0 / 360.0, 1.0, 0.67, 1.0))
})
.when(!is_macos, |this| {
this.window_control_area(gpui::WindowControlArea::Close)
})
.on_click(cx.listener(|this, _, window, cx| {
this.save_layout_state(window, cx);
window.remove_window();
@@ -2020,7 +2039,7 @@ impl Ashell {
.text_color(hsla(3.0 / 360.0, 1.0, 0.15, 0.7))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("✕")
.child("✕"),
),
)
.child(
@@ -2028,9 +2047,17 @@ impl Ashell {
.id("window-minimize")
.size(px(12.))
.rounded_full()
.bg(if is_active { hsla(39.0 / 360.0, 1.0, 0.59, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Yellow or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(39.0 / 360.0, 1.0, 0.59, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Min))
.bg(if is_active {
hsla(39.0 / 360.0, 1.0, 0.59, 1.0)
} else {
hsla(0.0, 0.0, 0.8, 1.0)
}) // Yellow or Inactive Grey
.group_hover("window-controls", |s| {
s.bg(hsla(39.0 / 360.0, 1.0, 0.59, 1.0))
})
.when(!is_macos, |this| {
this.window_control_area(gpui::WindowControlArea::Min)
})
.on_click(|_, window, _| window.minimize_window())
.hover(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.49, 1.0)))
.active(|s| s.bg(hsla(39.0 / 360.0, 1.0, 0.39, 1.0)))
@@ -2046,7 +2073,7 @@ impl Ashell {
.text_color(hsla(39.0 / 360.0, 1.0, 0.15, 0.8))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("−")
.child("−"),
),
)
.child(
@@ -2054,9 +2081,17 @@ impl Ashell {
.id("window-maximize")
.size(px(12.))
.rounded_full()
.bg(if is_active { hsla(127.0 / 360.0, 0.68, 0.47, 1.0) } else { hsla(0.0, 0.0, 0.8, 1.0) }) // Green or Inactive Grey
.group_hover("window-controls", |s| s.bg(hsla(127.0 / 360.0, 0.68, 0.47, 1.0)))
.when(!is_macos, |this| this.window_control_area(gpui::WindowControlArea::Max))
.bg(if is_active {
hsla(127.0 / 360.0, 0.68, 0.47, 1.0)
} else {
hsla(0.0, 0.0, 0.8, 1.0)
}) // Green or Inactive Grey
.group_hover("window-controls", |s| {
s.bg(hsla(127.0 / 360.0, 0.68, 0.47, 1.0))
})
.when(!is_macos, |this| {
this.window_control_area(gpui::WindowControlArea::Max)
})
.on_click(|_, window, _| {
if window.is_fullscreen() {
window.toggle_fullscreen();
@@ -2081,7 +2116,7 @@ impl Ashell {
.text_color(hsla(127.0 / 360.0, 1.0, 0.15, 0.8))
.opacity(0.0)
.group_hover("window-controls", |s| s.opacity(1.0))
.child("+")
.child("+"),
),
)
})
@@ -2315,11 +2350,12 @@ impl Ashell {
return this.render_home_page(cx).into_any_element();
}
let is_focused = path == this.focused_pane_path.as_slice();
let keyword_highlight = this.config.keyword_highlight();
let snapshot = this
.tabs
.iter()
.find(|t| &t.id == tab_id)
.map(TerminalTab::render_snapshot);
.map(|t| t.render_snapshot(keyword_highlight));
let Some(snapshot) = snapshot else {
return div().into_any_element();
};
@@ -2334,9 +2370,14 @@ 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| {
@@ -2373,44 +2414,38 @@ impl Ashell {
.and_then(|tab| tab.disconnected_reason.clone());
if let Some(reason) = disconnected_reason {
let tab_id_for_reconnect = tab_id.clone();
el = div()
.size_full()
.relative()
.child(el)
.child(
div()
.absolute()
.bottom_0()
.left_0()
.right_0()
el = div().size_full().relative().child(el).child(
div().absolute().bottom_0().left_0().right_0().child(
h_flex()
.w_full()
.items_center()
.gap_2()
.px_3()
.py_1()
.bg(cx.theme().danger.opacity(0.15))
.child(
h_flex()
.w_full()
.items_center()
.gap_2()
.px_3()
.py_1()
.bg(cx.theme().danger.opacity(0.15))
div()
.text_size(rems(0.85))
.text_color(cx.theme().danger)
.child(
div()
.text_size(rems(0.85))
.text_color(cx.theme().danger)
.child(t!("session_disconnected", "reason" = reason).to_string()),
)
.child(
div()
.text_size(rems(0.85))
.text_color(cx.theme().muted_foreground)
.child(format!("— {}", t!("press_enter_to_reconnect"))),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.retry_disconnected_tab(&tab_id_for_reconnect, cx);
}),
t!("session_disconnected", "reason" = reason)
.to_string(),
),
)
.child(
div()
.text_size(rems(0.85))
.text_color(cx.theme().muted_foreground)
.child(format!("— {}", t!("press_enter_to_reconnect"))),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _, _, cx| {
this.retry_disconnected_tab(&tab_id_for_reconnect, cx);
}),
),
);
),
);
}
let indicator_color = this
.tabs
@@ -2604,7 +2639,7 @@ impl Render for Ashell {
if let Some(scrollbar) = self.terminal_scrollbars.get(&active_id) {
if let Some(new_display_offset) = scrollbar.future_display_offset.take() {
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
let current = tab.render_snapshot().display_offset;
let current = tab.render_snapshot(false).display_offset;
match new_display_offset.cmp(&current) {
std::cmp::Ordering::Greater => {
tab.scroll_up_by(new_display_offset - current)
@@ -2647,6 +2682,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 +2769,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 +2845,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
View File
@@ -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;
+4 -6
View File
@@ -16,14 +16,12 @@ rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar, Copy, Paste,
ClosePane, Copy, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, Paste, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::{
Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup};
fn main() {
app::startup::sync_macos_launch_environment();
+372 -121
View File
@@ -1,7 +1,14 @@
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use argon2::Argon2;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit},
};
use directories::BaseDirs;
use rand::{RngCore, rngs::OsRng};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -10,6 +17,7 @@ use uuid::Uuid;
pub enum AuthMethod {
Password,
Key,
Config,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -176,6 +184,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 +298,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,
@@ -340,26 +351,33 @@ impl ConfigStore {
}
let mut cache = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
match serde_json::from_str::<ConfigFile>(&raw) {
let raw_bytes =
fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
let hardware_uuid = get_hardware_uuid();
match decrypt_config(&raw_bytes, &hardware_uuid) {
Ok(cache) => cache,
Err(err) => {
let backup_path = path.with_extension("json.bak");
if let Err(backup_err) = fs::write(&backup_path, raw.as_bytes()) {
tracing::warn!(
"failed to parse config {}; backup to {} also failed: {backup_err:#}; parse error: {err:#}",
path.display(),
backup_path.display(),
);
} else {
tracing::warn!(
"failed to parse config {}; backed up the original to {} and loaded defaults: {err:#}",
path.display(),
backup_path.display(),
);
Err(decrypt_err) => {
// Fallback to plain text JSON if decryption/parsing failed
match serde_json::from_slice::<ConfigFile>(&raw_bytes) {
Ok(cache) => cache,
Err(json_err) => {
let backup_path = path.with_extension("json.bak");
if let Err(backup_err) = fs::write(&backup_path, &raw_bytes) {
tracing::warn!(
"failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backup to {} also failed: {backup_err:#}",
path.display(),
backup_path.display(),
);
} else {
tracing::warn!(
"failed to parse config {} (decrypt err: {decrypt_err:#}, json err: {json_err:#}); backed up the original to {} and loaded defaults",
path.display(),
backup_path.display(),
);
}
ConfigFile::default()
}
}
ConfigFile::default()
}
}
} else {
@@ -435,7 +453,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 +465,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 +728,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
}
@@ -738,8 +772,9 @@ impl ConfigStore {
if self.path.as_os_str().is_empty() {
return Ok(());
}
let raw = serde_json::to_string_pretty(&self.cache)?;
fs::write(&self.path, raw)
let hardware_uuid = get_hardware_uuid();
let encrypted_bytes = encrypt_config(&self.cache, &hardware_uuid)?;
fs::write(&self.path, encrypted_bytes)
.with_context(|| format!("failed to write {}", self.path.display()))?;
#[cfg(unix)]
@@ -755,8 +790,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;
@@ -772,110 +813,126 @@ pub struct EnvProxy {
pub static ENV_PROXY: OnceLock<Option<EnvProxy>> = OnceLock::new();
pub async fn connect_proxy(session: &Session) -> Result<Box<dyn ProxyStream>> {
let target_host = &session.host;
let target_host = session.host.clone();
let target_port = session.port;
let session = session.clone();
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = {
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
(
session.proxy_type.clone(),
session.proxy_host.clone(),
session.proxy_port,
session.proxy_user.clone(),
session.proxy_password.clone(),
)
} else if config.cache.read_env_proxy && ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some() {
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
(
env_p.proxy_type.clone(),
env_p.host.clone(),
env_p.port,
env_p.user.clone(),
env_p.pass.clone(),
)
} else if config.cache.use_proxy {
(
config.cache.global_proxy_type.clone(),
config.cache.global_proxy_host.clone(),
config.cache.global_proxy_port,
config.cache.global_proxy_user.clone(),
config.cache.global_proxy_password.clone(),
)
} else {
("none".to_string(), String::new(), None, String::new(), String::new())
}
};
if proxy_type != "none" && (proxy_host.is_empty() || proxy_port.is_none()) {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
return Ok(Box::new(stream));
}
match proxy_type.as_str() {
"socks5" | "socks5h" => {
let proxy_port = proxy_port.unwrap_or(1080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
if !proxy_user.is_empty() {
let stream = tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
&proxy_user,
&proxy_password,
let connect_fut = async move {
let target_host = &target_host;
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let (proxy_type, proxy_host, proxy_port, proxy_user, proxy_password) = {
if !session.proxy_type.is_empty() && session.proxy_type != "none" {
(
session.proxy_type.clone(),
session.proxy_host.clone(),
session.proxy_port,
session.proxy_user.clone(),
session.proxy_password.clone(),
)
} else if config.cache.read_env_proxy
&& ENV_PROXY.get().and_then(|opt| opt.as_ref()).is_some()
{
let env_p = ENV_PROXY.get().and_then(|opt| opt.as_ref()).unwrap();
(
env_p.proxy_type.clone(),
env_p.host.clone(),
env_p.port,
env_p.user.clone(),
env_p.pass.clone(),
)
} else if config.cache.use_proxy {
(
config.cache.global_proxy_type.clone(),
config.cache.global_proxy_host.clone(),
config.cache.global_proxy_port,
config.cache.global_proxy_user.clone(),
config.cache.global_proxy_password.clone(),
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream))
} else {
let stream = tokio_socks::tcp::Socks5Stream::connect(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
(
"none".to_string(),
String::new(),
None,
String::new(),
String::new(),
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream))
}
}
"http" => {
let proxy_port = proxy_port.unwrap_or(8080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
};
use tokio::io::AsyncWriteExt;
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?;
let mut request = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
if !proxy_user.is_empty() {
use base64::Engine as _;
let auth = format!("{}:{}", proxy_user, proxy_password);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).await?;
let mut response = [0u8; 1024];
let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?;
let resp_str = String::from_utf8_lossy(&response[..n]);
if !resp_str.contains("200") && !resp_str.contains("established") {
return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str));
}
Ok(Box::new(stream))
}
_ => {
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?;
Ok(Box::new(stream))
return Ok(Box::new(stream) as Box<dyn ProxyStream>);
}
}
match proxy_type.as_str() {
"socks5" | "socks5h" => {
let proxy_port = proxy_port.unwrap_or(1080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
if !proxy_user.is_empty() {
let stream = tokio_socks::tcp::Socks5Stream::connect_with_password(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
&proxy_user,
&proxy_password,
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream) as Box<dyn ProxyStream>)
} else {
let stream = tokio_socks::tcp::Socks5Stream::connect(
proxy_addr.as_str(),
(target_host.as_str(), target_port),
)
.await
.map_err(|e| anyhow::anyhow!("SOCKS5 proxy connection failed: {}", e))?;
Ok(Box::new(stream) as Box<dyn ProxyStream>)
}
}
"http" => {
let proxy_port = proxy_port.unwrap_or(8080);
let proxy_addr = format!("{}:{}", proxy_host, proxy_port);
use tokio::io::AsyncWriteExt;
let mut stream = tokio::net::TcpStream::connect(&proxy_addr)
.await
.map_err(|e| anyhow::anyhow!("HTTP proxy connection failed: {}", e))?;
let mut request = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
if !proxy_user.is_empty() {
use base64::Engine as _;
let auth = format!("{}:{}", proxy_user, proxy_password);
let encoded = base64::engine::general_purpose::STANDARD.encode(auth);
request.push_str(&format!("Proxy-Authorization: Basic {}\r\n", encoded));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).await?;
let mut response = [0u8; 1024];
let n = tokio::io::AsyncReadExt::read(&mut stream, &mut response).await?;
let resp_str = String::from_utf8_lossy(&response[..n]);
if !resp_str.contains("200") && !resp_str.contains("established") {
return Err(anyhow::anyhow!("HTTP proxy CONNECT failed: {}", resp_str));
}
Ok(Box::new(stream) as Box<dyn ProxyStream>)
}
_ => {
let addr = format!("{}:{}", target_host, target_port);
let stream = tokio::net::TcpStream::connect(&addr).await?;
Ok(Box::new(stream) as Box<dyn ProxyStream>)
}
}
};
tokio::time::timeout(std::time::Duration::from_secs(16), connect_fut)
.await
.map_err(|_| anyhow::anyhow!("connection timed out after 16 seconds"))?
}
pub fn active_proxy(session: &Session) -> Option<(String, String, Option<u16>)> {
@@ -889,7 +946,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,13 +966,205 @@ 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 {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EncryptedConfigEnvelope {
format_version: u32,
kdf: String,
cipher: String,
salt: String,
nonce: String,
payload: String,
}
fn get_hardware_uuid() -> String {
#[cfg(target_os = "macos")]
{
if let Ok(output) = std::process::Command::new("ioreg")
.args(&["-rd1", "-c", "IOPlatformExpertDevice"])
.output()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("IOPlatformUUID") {
if let Some(uuid) = line.split('"').nth(3) {
let uuid = uuid.trim().to_string();
if !uuid.is_empty() {
return uuid;
}
}
}
}
}
}
#[cfg(target_os = "linux")]
{
if let Ok(uuid) = std::fs::read_to_string("/sys/class/dmi/id/product_uuid") {
let uuid = uuid.trim().to_string();
if !uuid.is_empty() {
return uuid;
}
}
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
let id = id.trim().to_string();
if !id.is_empty() {
return id;
}
}
if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
let id = id.trim().to_string();
if !id.is_empty() {
return id;
}
}
}
#[cfg(target_os = "windows")]
{
if let Ok(output) = std::process::Command::new("reg")
.args(&[
"query",
"HKLM\\SOFTWARE\\Microsoft\\Cryptography",
"/v",
"MachineGuid",
])
.output()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("MachineGuid") {
if let Some(guid) = line.split_whitespace().last() {
let guid = guid.trim().to_string();
if !guid.is_empty() {
return guid;
}
}
}
}
}
if let Ok(output) = std::process::Command::new("wmic")
.args(&["csproduct", "get", "uuid"])
.output()
{
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
if lines.len() >= 2 {
let uuid = lines[1].trim().to_string();
if !uuid.is_empty() {
return uuid;
}
}
}
}
"ashell-default-hardware-uuid-fallback".to_string()
}
fn encrypt_config(config: &ConfigFile, password: &str) -> Result<Vec<u8>> {
let mut salt = [0u8; 16];
let mut nonce = [0u8; 24];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut nonce);
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password.as_bytes(), &salt, &mut key)
.map_err(|err| anyhow::anyhow!("derive encryption key: {err}"))?;
let plaintext = serde_json::to_vec(config).context("serialize config")?;
let ciphertext = XChaCha20Poly1305::new((&key).into())
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
.map_err(|_| anyhow::anyhow!("encrypt config payload"))?;
serde_json::to_vec_pretty(&EncryptedConfigEnvelope {
format_version: 1,
kdf: "argon2id".to_string(),
cipher: "xchacha20poly1305".to_string(),
salt: STANDARD.encode(salt),
nonce: STANDARD.encode(nonce),
payload: STANDARD.encode(ciphertext),
})
.context("serialize encrypted config envelope")
}
fn decrypt_config(raw: &[u8], password: &str) -> Result<ConfigFile> {
let envelope: EncryptedConfigEnvelope =
serde_json::from_slice(raw).context("parse encrypted config envelope")?;
if envelope.format_version != 1
|| envelope.kdf != "argon2id"
|| envelope.cipher != "xchacha20poly1305"
{
return Err(anyhow::anyhow!("unsupported encrypted config format"));
}
let salt = STANDARD
.decode(envelope.salt)
.context("decode config salt")?;
let nonce = STANDARD
.decode(envelope.nonce)
.context("decode config nonce")?;
if nonce.len() != 24 {
return Err(anyhow::anyhow!("invalid config nonce"));
}
let ciphertext = STANDARD
.decode(envelope.payload)
.context("decode encrypted config payload")?;
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password.as_bytes(), &salt, &mut key)
.map_err(|err| anyhow::anyhow!("derive encryption key: {err}"))?;
let plaintext = XChaCha20Poly1305::new((&key).into())
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| {
anyhow::anyhow!("cannot decrypt config; hardware UUID mismatch or corrupted data")
})?;
serde_json::from_slice(&plaintext).context("parse decrypted config")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_hardware_uuid() {
let uuid = get_hardware_uuid();
assert!(!uuid.is_empty());
}
#[test]
fn test_config_encryption_roundtrip() {
let config = ConfigFile::default();
let password = "test-password-123";
let encrypted = encrypt_config(&config, password).unwrap();
// Ensure it doesn't contain plain text fields of default config
let encrypted_str = String::from_utf8_lossy(&encrypted);
assert!(!encrypted_str.contains("Maple Mono NF CN"));
assert!(encrypted_str.contains("argon2id"));
let decrypted = decrypt_config(&encrypted, password).unwrap();
assert_eq!(decrypted.terminal_font_family, config.terminal_font_family);
// Decrypt with wrong password should fail
assert!(decrypt_config(&encrypted, "wrong-password").is_err());
}
}
+111 -24
View File
@@ -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},
};
@@ -102,13 +102,14 @@ 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::Key => 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;
@@ -151,6 +152,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 +204,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 +374,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();
@@ -715,6 +783,13 @@ impl Ashell {
}
pub(crate) fn handle_tab_close(&mut self, id: String) {
if self
.connection_progress
.as_ref()
.map_or(false, |p| p.tab_id == id)
{
self.connection_progress = None;
}
let group_ix = self
.tab_groups
.iter()
@@ -898,12 +973,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();
}
}
@@ -919,7 +1010,7 @@ impl Ashell {
self.active_tab
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
.map(TerminalTab::render_snapshot)
.map(|t| t.render_snapshot(self.config.keyword_highlight()))
}
pub(crate) fn active_kind(&self) -> Option<TabKind> {
@@ -958,10 +1049,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={}",
+179
View File
@@ -0,0 +1,179 @@
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");
}
}
+96
View File
@@ -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)
}
+106 -70
View File
@@ -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,107 @@ 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 +962,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 +1004,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;
+97 -13
View File
@@ -39,6 +39,26 @@ impl LayoutRect {
}
}
#[derive(Clone)]
struct LayoutUnderline {
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
impl LayoutUnderline {
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
let thickness = px(1.0);
let position = point(
origin.x + metrics.cell_width * self.col as f32,
origin.y + metrics.line_height * (self.row as f32 + 1.0) - thickness,
);
let size = gpui::size(metrics.cell_width * self.cells as f32, thickness);
window.paint_quad(fill(Bounds::new(position, size), self.color));
}
}
#[derive(Clone)]
struct BatchedTextRun {
row: i32,
@@ -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,19 @@ 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 +441,26 @@ 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 +513,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> {
@@ -471,7 +526,9 @@ impl TerminalElement {
let cursor_style = self.view.read(cx).cursor_style;
let show_cursor = match cursor_style {
CursorStyle::Blink | CursorStyle::BeamBlink => {
if let Ok(duration) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
if let Ok(duration) =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
{
(duration.as_millis() / 600) % 2 == 0
} else {
true
@@ -543,7 +600,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.
@@ -551,7 +608,7 @@ impl Element for TerminalElement {
let tab_id = self.tab_id.clone();
let _ = view.update(cx, |this, cx| {
let old_bounds = this.terminal_bounds.insert(tab_id.clone(), bounds);
// Sync PTY size unconditionally on every prepaint layout pass to ensure
// absolute synchronization with GPUI layout regardless of intermediate events.
let line_height = this.terminal_line_height();
@@ -560,11 +617,11 @@ impl Element for TerminalElement {
let h = bounds.size.height.as_f32();
let cols = (w / cell_width).floor().max(1.0) as u16;
let rows = (h / line_height).floor().max(1.0) as u16;
if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.resize(cols, rows);
}
if old_bounds != Some(bounds) {
cx.notify();
}
@@ -580,6 +637,7 @@ impl Element for TerminalElement {
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
underlines,
}
}
@@ -595,9 +653,14 @@ impl Element for TerminalElement {
) {
// Compute a vertical offset to center the text grid vertically,
// distributing the leftover pixel remainder evenly to the top and bottom.
let grid_height = prepaint.metrics.line_height * (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32()).floor();
let grid_height = prepaint.metrics.line_height
* (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32())
.floor();
let y_offset = ((prepaint.bounds.size.height - grid_height) / 2.0).max(px(0.0));
let draw_origin = point(prepaint.bounds.origin.x, prepaint.bounds.origin.y + y_offset);
let draw_origin = point(
prepaint.bounds.origin.x,
prepaint.bounds.origin.y + y_offset,
);
for rect in &prepaint.rects {
rect.paint(draw_origin, prepaint.metrics, window);
@@ -607,11 +670,15 @@ 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();
let y = draw_origin.y.as_f32()
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
let x =
draw_origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y =
draw_origin.y.as_f32() + block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
block.c,
@@ -746,6 +813,23 @@ 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;
+582 -174
View File
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use gpui::Hsla;
use crate::terminal::RenderCell;
use gpui::Hsla;
use std::collections::HashMap;
trait HslaExt {
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
@@ -15,10 +15,19 @@ impl HslaExt for Hsla {
let min = rf.min(gf).min(bf);
let l = (max + min) / 2.0;
if max == min {
return Hsla { h: 0.0, s: 0.0, l, a: 1.0 };
return Hsla {
h: 0.0,
s: 0.0,
l,
a: 1.0,
};
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == rf {
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
} else if max == gf {
@@ -33,58 +42,58 @@ impl HslaExt for Hsla {
#[derive(Debug, Clone)]
struct HighlightColors {
// Log levels
error: Hsla, // ERROR, ERR
critical: Hsla, // PANIC, FATAL, EMERGENCY, CRITICAL
warning: Hsla, // WARNING, WARN
info: Hsla, // INFO, NOTICE
debug: Hsla, // DEBUG, TRACE, DBG
alert: Hsla, // ALERT
error: Hsla, // ERROR, ERR
critical: Hsla, // PANIC, FATAL, EMERGENCY, CRITICAL
warning: Hsla, // WARNING, WARN
info: Hsla, // INFO, NOTICE
debug: Hsla, // DEBUG, TRACE, DBG
alert: Hsla, // ALERT
// Status indicators
success: Hsla, // SUCCESS, OK, PASS, DONE, COMPLETED
failure: Hsla, // FAILED, FAIL, FAILURE
pending: Hsla, // PENDING, WAITING, PROCESSING
running: Hsla, // RUNNING, ACTIVE, EXECUTING
stopped: Hsla, // STOPPED, INACTIVE, HALTED, IDLE
skipped: Hsla, // SKIPPED, SKIP
success: Hsla, // SUCCESS, OK, PASS, DONE, COMPLETED
failure: Hsla, // FAILED, FAIL, FAILURE
pending: Hsla, // PENDING, WAITING, PROCESSING
running: Hsla, // RUNNING, ACTIVE, EXECUTING
stopped: Hsla, // STOPPED, INACTIVE, HALTED, IDLE
skipped: Hsla, // SKIPPED, SKIP
// Network
network_up: Hsla, // UP, ONLINE, CONNECTED
network_down: Hsla, // DOWN, OFFLINE, UNREACHABLE
timeout: Hsla, // TIMEOUT, TIMED OUT
refused: Hsla, // REFUSED, REJECTED, DENIED
network_up: Hsla, // UP, ONLINE, CONNECTED
network_down: Hsla, // DOWN, OFFLINE, UNREACHABLE
timeout: Hsla, // TIMEOUT, TIMED OUT
refused: Hsla, // REFUSED, REJECTED, DENIED
// Security & Auth
security: Hsla, // SSH, SSL, TLS, CERTIFICATE
auth: Hsla, // AUTHENTICATED, AUTHORIZED, LOGIN
danger: Hsla, // ROOT, SUDO, PASSWORD, SECRET
security: Hsla, // SSH, SSL, TLS, CERTIFICATE
auth: Hsla, // AUTHENTICATED, AUTHORIZED, LOGIN
danger: Hsla, // ROOT, SUDO, PASSWORD, SECRET
// Operations
started: Hsla, // START, BOOT, STARTING
stopped_op: Hsla, // STOP, SHUTDOWN, STOPPING
restart: Hsla, // RESTART, RESTARTING
deploy: Hsla, // DEPLOY, DEPLOYED, DEPLOYMENT
crashed: Hsla, // CRASH, CRASHED, SIGSEGV
started: Hsla, // START, BOOT, STARTING
stopped_op: Hsla, // STOP, SHUTDOWN, STOPPING
restart: Hsla, // RESTART, RESTARTING
deploy: Hsla, // DEPLOY, DEPLOYED, DEPLOYMENT
crashed: Hsla, // CRASH, CRASHED, SIGSEGV
// Resources
memory: Hsla, // MEMORY, RAM, SWAP, HEAP
cpu: Hsla, // CPU, PROCESSOR, CORE
disk: Hsla, // DISK, STORAGE, PARTITION, MOUNT
memory: Hsla, // MEMORY, RAM, SWAP, HEAP
cpu: Hsla, // CPU, PROCESSOR, CORE
disk: Hsla, // DISK, STORAGE, PARTITION, MOUNT
// HTTP codes
http_2xx: Hsla, // 200-299 Success
http_3xx: Hsla, // 300-399 Redirect
http_4xx: Hsla, // 400-499 Client Error
http_5xx: Hsla, // 500-599 Server Error
http_2xx: Hsla, // 200-299 Success
http_3xx: Hsla, // 300-399 Redirect
http_4xx: Hsla, // 400-499 Client Error
http_5xx: Hsla, // 500-599 Server Error
// Dev / Exceptions
exception: Hsla, // Exception, Traceback, Error type
deprecated: Hsla, // DEPRECATED, TODO, FIXME
exception: Hsla, // Exception, Traceback, Error type
deprecated: Hsla, // DEPRECATED, TODO, FIXME
// Existing
network: Hsla, // IP addresses
url: Hsla, // http://, https://
port: Hsla, // :22, :443, etc.
network: Hsla, // IP addresses
url: Hsla, // http://, https://
port: Hsla, // :22, :443, etc.
}
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
@@ -100,58 +109,58 @@ fn hsla(r: u8, g: u8, b: u8) -> Hsla {
fn highlight_colors() -> HighlightColors {
HighlightColors {
// Log levels
error: hsla(224, 96, 96), // #E06060 red
critical: hsla(255, 50, 50), // #FF3232 bright red
warning: hsla(232, 201, 122), // #E8C97A yellow
info: hsla(108, 180, 238), // #6CB4EE blue
debug: hsla(130, 140, 155), // #828C9B gray
alert: hsla(213, 126, 234), // #D57EEA bright magenta
error: hsla(224, 96, 96), // #E06060 red
critical: hsla(255, 50, 50), // #FF3232 bright red
warning: hsla(232, 201, 122), // #E8C97A yellow
info: hsla(108, 180, 238), // #6CB4EE blue
debug: hsla(130, 140, 155), // #828C9B gray
alert: hsla(213, 126, 234), // #D57EEA bright magenta
// Status
success: hsla(126, 198, 153), // #7EC699 green
failure: hsla(232, 168, 124), // #E8A87C orange
pending: hsla(232, 201, 122), // #E8C97A yellow
running: hsla( 86, 206, 234), // #56CEEA cyan
stopped: hsla(160, 165, 175), // #A0A5AF gray
skipped: hsla(199, 146, 234), // #C792EA purple
success: hsla(126, 198, 153), // #7EC699 green
failure: hsla(232, 168, 124), // #E8A87C orange
pending: hsla(232, 201, 122), // #E8C97A yellow
running: hsla(86, 206, 234), // #56CEEA cyan
stopped: hsla(160, 165, 175), // #A0A5AF gray
skipped: hsla(199, 146, 234), // #C792EA purple
// Network
network_up: hsla(126, 198, 153), // #7EC699 green
network_down: hsla(224, 96, 96), // #E06060 red
timeout: hsla(213, 126, 234), // #D57EEA magenta
refused: hsla(245, 160, 80), // #F5A050 orange
network_up: hsla(126, 198, 153), // #7EC699 green
network_down: hsla(224, 96, 96), // #E06060 red
timeout: hsla(213, 126, 234), // #D57EEA magenta
refused: hsla(245, 160, 80), // #F5A050 orange
// Security
security: hsla( 86, 206, 234), // #56CEEA cyan
auth: hsla(126, 198, 153), // #7EC699 green
danger: hsla(180, 50, 50), // #B43232 dark red
security: hsla(86, 206, 234), // #56CEEA cyan
auth: hsla(126, 198, 153), // #7EC699 green
danger: hsla(180, 50, 50), // #B43232 dark red
// Operations
started: hsla(126, 198, 153), // #7EC699 green
stopped_op: hsla(224, 96, 96), // #E06060 red
restart: hsla(232, 201, 122), // #E8C97A yellow
deploy: hsla(100, 210, 140), // #64D28C bright green
crashed: hsla(255, 50, 50), // #FF3232 bright red
started: hsla(126, 198, 153), // #7EC699 green
stopped_op: hsla(224, 96, 96), // #E06060 red
restart: hsla(232, 201, 122), // #E8C97A yellow
deploy: hsla(100, 210, 140), // #64D28C bright green
crashed: hsla(255, 50, 50), // #FF3232 bright red
// Resources
memory: hsla(199, 146, 234), // #C792EA purple
cpu: hsla( 86, 206, 234), // #56CEEA cyan
disk: hsla(108, 180, 238), // #6CB4EE blue
memory: hsla(199, 146, 234), // #C792EA purple
cpu: hsla(86, 206, 234), // #56CEEA cyan
disk: hsla(108, 180, 238), // #6CB4EE blue
// HTTP codes
http_2xx: hsla(126, 198, 153), // #7EC699 green
http_3xx: hsla( 86, 206, 234), // #56CEEA cyan
http_4xx: hsla(232, 201, 122), // #E8C97A yellow
http_5xx: hsla(224, 96, 96), // #E06060 red
http_2xx: hsla(126, 198, 153), // #7EC699 green
http_3xx: hsla(86, 206, 234), // #56CEEA cyan
http_4xx: hsla(232, 201, 122), // #E8C97A yellow
http_5xx: hsla(224, 96, 96), // #E06060 red
// Dev
exception: hsla(224, 96, 96), // #E06060 red
deprecated: hsla(245, 160, 80), // #F5A050 orange
exception: hsla(224, 96, 96), // #E06060 red
deprecated: hsla(245, 160, 80), // #F5A050 orange
// Existing
network: hsla(199, 146, 234), // #C792EA purple
url: hsla( 86, 212, 199), // #56D4C7 teal
port: hsla(130, 170, 200), // #82AAC8 muted teal
network: hsla(199, 146, 234), // #C792EA purple
url: hsla(86, 212, 199), // #56D4C7 teal
port: hsla(130, 170, 200), // #82AAC8 muted teal
}
}
@@ -202,18 +211,40 @@ fn highlight_http_codes(
// Specific HTTP codes to highlight
const HTTP_CODES: &[(u16, bool)] = &[
// 2xx
(200, true), (201, true), (202, true), (204, true), (206, true),
(200, true),
(201, true),
(202, true),
(204, true),
(206, true),
// 3xx
(301, true), (302, true), (304, true), (307, true), (308, true),
(301, true),
(302, true),
(304, true),
(307, true),
(308, true),
// 4xx
(400, true), (401, true), (403, true), (404, true), (405, true),
(408, true), (409, true), (410, true), (422, true), (429, true),
(400, true),
(401, true),
(403, true),
(404, true),
(405, true),
(408, true),
(409, true),
(410, true),
(422, true),
(429, true),
// 5xx
(500, true), (502, true), (503, true), (504, true),
(500, true),
(502, true),
(503, true),
(504, true),
];
for i in 0..len.saturating_sub(2) {
if !bytes[i].is_ascii_digit() || !bytes[i + 1].is_ascii_digit() || !bytes[i + 2].is_ascii_digit() {
if !bytes[i].is_ascii_digit()
|| !bytes[i + 1].is_ascii_digit()
|| !bytes[i + 2].is_ascii_digit()
{
continue;
}
@@ -249,10 +280,7 @@ fn highlight_http_codes(
}
}
pub fn highlight_cells(
cells: &[RenderCell],
rows: usize,
) -> HashMap<(i32, i32), Hsla> {
pub fn highlight_cells(cells: &[RenderCell], rows: usize) -> HashMap<(i32, i32), Hsla> {
let colors = highlight_colors();
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
@@ -289,178 +317,458 @@ pub fn highlight_cells(
let text = chars_buf.as_str();
// ── 1. Critical errors (highest priority) ──────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["PANIC", "EMERGENCY", "FATAL", "SEGFAULT", "CRITICAL",
"OOM", "OUT OF MEMORY", "KERNEL PANIC", "CORE DUMPED", "BUS ERROR"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"PANIC",
"EMERGENCY",
"FATAL",
"SEGFAULT",
"CRITICAL",
"OOM",
"OUT OF MEMORY",
"KERNEL PANIC",
"CORE DUMPED",
"BUS ERROR",
],
colors.critical,
);
// ── 2. Error keywords ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ERROR", "ERR"], colors.error);
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["ERROR", "ERR"],
colors.error,
);
// ── 3. Alert ───────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ALERT"], colors.alert);
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["ALERT"],
colors.alert,
);
// ── 4. Warning keywords ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["WARNING", "WARN"], colors.warning);
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["WARNING", "WARN"],
colors.warning,
);
// ── 5. Info keywords ───────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["INFO", "INFORMATION", "NOTICE"], colors.info);
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["INFO", "INFORMATION", "NOTICE"],
colors.info,
);
// ── 6. Debug keywords ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEBUG", "DBG", "TRACE"], colors.debug);
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["DEBUG", "DBG", "TRACE"],
colors.debug,
);
// ── 7. Success status ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SUCCESS", "SUCCEEDED", "SUCCESSFUL", "PASSED", "PASS",
"OK", "DONE", "COMPLETED", "FINISHED", "COMPLETE"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"SUCCESS",
"SUCCEEDED",
"SUCCESSFUL",
"PASSED",
"PASS",
"OK",
"DONE",
"COMPLETED",
"FINISHED",
"COMPLETE",
],
colors.success,
);
// ── 8. Failure status ──────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["FAILED", "FAILURE", "FAIL", "NOT OK"],
colors.failure,
);
// ── 9. Pending / Waiting ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["PENDING", "WAITING", "PROCESSING", "IN PROGRESS", "QUEUED"],
colors.pending,
);
// ── 10. Running / Active ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["RUNNING", "ACTIVE", "EXECUTING", "IN_PROGRESS", "LIVE"],
colors.running,
);
// ── 11. Stopped / Inactive ─────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["STOPPED", "INACTIVE", "HALTED", "IDLE", "PAUSED"],
colors.stopped,
);
// ── 12. Skipped ────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SKIPPED", "SKIP", "SKIPPING"], colors.skipped);
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["SKIPPED", "SKIP", "SKIPPING"],
colors.skipped,
);
// ── 13. Network UP ─────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["UP", "ONLINE", "CONNECTED", "REACHABLE", "LISTENING",
"ESTABLISHED", "LINK UP"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"UP",
"ONLINE",
"CONNECTED",
"REACHABLE",
"LISTENING",
"ESTABLISHED",
"LINK UP",
],
colors.network_up,
);
// ── 14. Network DOWN ───────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DOWN", "OFFLINE", "UNREACHABLE", "DISCONNECTED",
"NOT LISTENING", "LINK DOWN", "NO CARRIER"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"DOWN",
"OFFLINE",
"UNREACHABLE",
"DISCONNECTED",
"NOT LISTENING",
"LINK DOWN",
"NO CARRIER",
],
colors.network_down,
);
// ── 15. Timeout ────────────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["TIMEOUT", "TIMED OUT", "TIMEOUTS", "ETIMEDOUT", "SLOW", "LATENCY"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"TIMEOUT",
"TIMED OUT",
"TIMEOUTS",
"ETIMEDOUT",
"SLOW",
"LATENCY",
],
colors.timeout,
);
// ── 16. Refused / Denied ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["REFUSED", "REJECTED", "DENIED", "PERMISSION DENIED",
"ACCESS DENIED", "FORBIDDEN", "BLOCKED", "DROP"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"REFUSED",
"REJECTED",
"DENIED",
"PERMISSION DENIED",
"ACCESS DENIED",
"FORBIDDEN",
"BLOCKED",
"DROP",
],
colors.refused,
);
// ── 17. Security / Protocol ────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["SSH", "SSHD", "SSL", "TLS", "HTTPS", "CERTIFICATE", "CERT",
"FIREWALL", "IPTABLES", "ACL", "WAF"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"SSH",
"SSHD",
"SSL",
"TLS",
"HTTPS",
"CERTIFICATE",
"CERT",
"FIREWALL",
"IPTABLES",
"ACL",
"WAF",
],
colors.security,
);
// ── 18. Authentication ─────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["AUTHENTICATED", "ACCEPTED", "AUTHORIZED", "LOGIN", "LOGOUT",
"LOGGED IN", "LOGGED OUT", "SESSION"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"AUTHENTICATED",
"ACCEPTED",
"AUTHORIZED",
"LOGIN",
"LOGOUT",
"LOGGED IN",
"LOGGED OUT",
"SESSION",
],
colors.auth,
);
// ── 19. Danger (root/sudo/secrets) ─────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["ROOT", "SUDO", "UID=0", "PASSWORD", "SECRET", "TOKEN",
"API_KEY", "APIKEY", "PRIVATE KEY", "CREDENTIALS"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"ROOT",
"SUDO",
"UID=0",
"PASSWORD",
"SECRET",
"TOKEN",
"API_KEY",
"APIKEY",
"PRIVATE KEY",
"CREDENTIALS",
],
colors.danger,
);
// ── 20. Operations: Start ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STARTED", "START", "STARTING", "BOOT", "BOOTING", "LAUNCHED", "LAUNCH"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"STARTED", "START", "STARTING", "BOOT", "BOOTING", "LAUNCHED", "LAUNCH",
],
colors.started,
);
// ── 21. Operations: Stop ───────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["STOPPED", "STOP", "STOPPING", "SHUTDOWN", "SHUTTING DOWN", "TERMINATED"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"STOPPED",
"STOP",
"STOPPING",
"SHUTDOWN",
"SHUTTING DOWN",
"TERMINATED",
],
colors.stopped_op,
);
// ── 22. Operations: Restart ────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["RESTARTED", "RESTART", "RESTARTING", "RELOAD", "RELOADED", "RELOADING"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"RESTARTED",
"RESTART",
"RESTARTING",
"RELOAD",
"RELOADED",
"RELOADING",
],
colors.restart,
);
// ── 23. Operations: Deploy ─────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEPLOYED", "DEPLOYMENT", "DEPLOYING", "DEPLOY",
"ROLLBACK", "ROLLED BACK", "ROLLING BACK", "UPGRADE", "UPGRADED"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"DEPLOYED",
"DEPLOYMENT",
"DEPLOYING",
"DEPLOY",
"ROLLBACK",
"ROLLED BACK",
"ROLLING BACK",
"UPGRADE",
"UPGRADED",
],
colors.deploy,
);
// ── 24. Operations: Crash ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["CRASH", "CRASHED", "CRASHING", "SIGSEGV", "SIGABRT", "SIGKILL",
"DIED", "EXITED", "EXIT CODE", "CORE DUMP"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"CRASH",
"CRASHED",
"CRASHING",
"SIGSEGV",
"SIGABRT",
"SIGKILL",
"DIED",
"EXITED",
"EXIT CODE",
"CORE DUMP",
],
colors.crashed,
);
// ── 25. Resources: Memory ──────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&["MEMORY", "RAM", "HEAP", "STACK", "SWAP", "MEM"],
colors.memory,
);
// ── 26. Resources: CPU ─────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["CPU", "PROCESSOR", "CORE", "CORES", "THREAD", "THREADS", "LOAD"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"CPU",
"PROCESSOR",
"CORE",
"CORES",
"THREAD",
"THREADS",
"LOAD",
],
colors.cpu,
);
// ── 27. Resources: Disk ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DISK", "STORAGE", "PARTITION", "MOUNT", "FILESYSTEM",
"INODE", "IOPS", "READ", "WRITE"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"DISK",
"STORAGE",
"PARTITION",
"MOUNT",
"FILESYSTEM",
"INODE",
"IOPS",
"READ",
"WRITE",
],
colors.disk,
);
// ── 28. Dev: Exceptions ────────────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["EXCEPTION", "TRACEBACK", "THROW", "THROWN", "STACKTRACE",
"TYPEERROR", "VALUEERROR", "KEYERROR", "ATTRIBUTEERROR",
"INDEXERROR", "RUNTIMEERROR", "IOERROR", "OSERROR",
"NULLPOINTER", "NPE", "SEGFAULT"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"EXCEPTION",
"TRACEBACK",
"THROW",
"THROWN",
"STACKTRACE",
"TYPEERROR",
"VALUEERROR",
"KEYERROR",
"ATTRIBUTEERROR",
"INDEXERROR",
"RUNTIMEERROR",
"IOERROR",
"OSERROR",
"NULLPOINTER",
"NPE",
"SEGFAULT",
],
colors.exception,
);
// ── 29. Dev: Deprecated / TODO ─────────────────────────
highlight_keywords(&mut map, text, &byte_to_col, row_i32,
&["DEPRECATED", "TODO", "FIXME", "HACK", "XXX", "WORKAROUND", "TEMPORARY"],
highlight_keywords(
&mut map,
text,
&byte_to_col,
row_i32,
&[
"DEPRECATED",
"TODO",
"FIXME",
"HACK",
"XXX",
"WORKAROUND",
"TEMPORARY",
],
colors.deprecated,
);
@@ -477,16 +785,6 @@ 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) {
let port_len = find_port_len(&text[m..]);
@@ -497,6 +795,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
}
@@ -530,11 +843,7 @@ fn find_ip_len(text: &str) -> usize {
len += 1;
}
if dots == 3 && digits > 0 {
len
} else {
0
}
if dots == 3 && digits > 0 { len } else { 0 }
}
fn find_ip_addresses(text: &str) -> Vec<usize> {
@@ -543,16 +852,12 @@ fn find_ip_addresses(text: &str) -> Vec<usize> {
let len = bytes.len();
for i in 0..len {
if bytes[i].is_ascii_digit()
&& (i == 0 || is_boundary(bytes[i - 1] as char))
{
if bytes[i].is_ascii_digit() && (i == 0 || is_boundary(bytes[i - 1] as char)) {
let remaining = &text[i..];
let ip_len = find_ip_len(remaining);
if ip_len > 0 {
let ip_str = &remaining[..ip_len];
let valid = ip_str
.split('.')
.all(|octet| octet.parse::<u8>().is_ok());
let valid = ip_str.split('.').all(|octet| octet.parse::<u8>().is_ok());
if valid {
positions.push(i);
}
@@ -630,3 +935,106 @@ 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
}
+35 -5
View File
@@ -23,6 +23,7 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cmd_ctrl_pressed = event.keystroke.modifiers.platform;
// If the search input is focused, skip terminal key processing
// so the input can handle text entry, paste, etc. normally.
if self
@@ -164,7 +165,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
@@ -203,7 +204,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
@@ -235,7 +236,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
@@ -290,7 +291,7 @@ impl Ashell {
return;
};
if tab.render_snapshot().display_offset > 0 {
if tab.render_snapshot(false).display_offset > 0 {
tab.scroll_to_bottom();
}
tab.clear_selection();
@@ -379,6 +380,35 @@ 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 +484,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)> {
+24 -13
View File
@@ -1,7 +1,7 @@
pub mod custom_blocks;
pub mod element;
pub mod input;
pub mod highlight;
pub mod input;
use std::sync::mpsc::Sender;
@@ -116,6 +116,7 @@ impl BackendTx {
pub struct TerminalTab {
pub id: String,
pub title: String,
pub dynamic_title: String,
pub kind: TabKind,
pub status: String,
pub connected: bool,
@@ -134,7 +135,12 @@ pub struct TerminalTab {
pub rows: u16,
pub backend: std::sync::Arc<std::sync::Mutex<BackendTx>>,
pub scroll_pixel_y: f32,
pub(crate) highlight_cache: std::cell::RefCell<Option<(Vec<RenderCell>, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>,
pub(crate) highlight_cache: std::cell::RefCell<
Option<(
Vec<RenderCell>,
std::collections::HashMap<(i32, i32), gpui::Hsla>,
)>,
>,
}
#[derive(Clone, Copy)]
@@ -233,7 +239,8 @@ impl TerminalTab {
let shared_backend = std::sync::Arc::new(std::sync::Mutex::new(backend));
Self {
id: id.clone(),
title,
title: title.clone(),
dynamic_title: title,
kind,
status,
connected: matches!(kind, TabKind::Local),
@@ -242,7 +249,7 @@ impl TerminalTab {
backend_initialized: true,
session: None,
processor: Processor::new(),
term: new_term(100, 30, shared_backend.clone(), id, events),
term: new_term(100, 30, shared_backend.clone(), id, events.clone()),
cols: 100,
rows: 30,
backend: shared_backend,
@@ -312,7 +319,7 @@ impl TerminalTab {
self.term.mode().contains(TermMode::APP_CURSOR)
}
pub fn render_snapshot(&self) -> RenderSnapshot {
pub fn render_snapshot(&self, keyword_highlight: bool) -> RenderSnapshot {
let rows = self.rows;
let cols = self.cols;
let content = self.term.renderable_content();
@@ -342,15 +349,13 @@ impl TerminalTab {
}
// Get highlights from cache or recompute, only if keyword_highlight is enabled.
let is_enabled = crate::session::config::ConfigStore::load()
.map(|c| c.keyword_highlight())
.unwrap_or(false);
let is_enabled = keyword_highlight;
let highlights = if is_enabled {
let mut cache = self.highlight_cache.borrow_mut();
let cache_valid = cache.as_ref().is_some_and(|(cached_cells, _)| {
cached_cells == &cells
});
let cache_valid = cache
.as_ref()
.is_some_and(|(cached_cells, _)| cached_cells == &cells);
if cache_valid {
cache.as_ref().unwrap().1.clone()
} else {
@@ -503,7 +508,10 @@ fn viewport_selection_from_range(
} = selection.as_ref().copied()?;
let top_point = viewport_to_point(display_offset, Point::new(0, Column(0)));
let bottom_point = viewport_to_point(display_offset, Point::new(rows.saturating_sub(1), Column(0)));
let bottom_point = viewport_to_point(
display_offset,
Point::new(rows.saturating_sub(1), Column(0)),
);
let top_line = top_point.line;
let bottom_line = bottom_point.line;
@@ -521,7 +529,10 @@ fn viewport_selection_from_range(
} else if end.line > bottom_line {
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
} else {
point_to_viewport(display_offset, end).unwrap_or(Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1))))
point_to_viewport(display_offset, end).unwrap_or(Point::new(
rows.saturating_sub(1),
Column(cols.saturating_sub(1)),
))
};
Some(ViewportSelection {