Compare commits

...
15 Commits
Author SHA1 Message Date
TomZz c3201e7c30 chore: bump version to v0.3.11 2026-06-15 01:14:48 +08:00
TomZz 2d5062ed75 feat: implement pixel-perfect custom rendering for block elements, powerline and box drawing
- Render block elements (U+2580-U+259F), box drawing lines, and powerline characters using precise GPUI vector paths instead of fonts.

- Snap logical coordinates to exact physical pixels using window.scale_factor() to perfectly eliminate SDF anti-aliasing gaps and bleeding.

- Fixes visual seams in terminal UI layouts.
2026-06-15 01:14:42 +08:00
TomZz d7d6faeeb1 feat: optimize transfer history dialog UI and logic
- Add an independent scrollbar track to prevent it from overlapping with the content list
- Add a "Clear History" button with text label, placed seamlessly next to the close button in the custom header
- Implement a 100-record limit for transfer history and display this limit in the header
- Persist the cleared transfer history into the session config immediately after clearing
- Disable default dialog close button and reconstruct header to avoid state update collision and panic

Resolves #25
2026-06-14 23:58:46 +08:00
TomZz 19a4274473 feat: enhance UI for active tabs and settings dialog
- Active Tab: Now uses `FontWeight::BOLD`, `text_base()` font size, and the primary theme color. This makes it far more distinct from inactive tabs, especially in themes where background differences are subtle.
- Settings Dialog: Overrode the default sidebar style to use the main dialog background (`cx.theme().background`), seamlessly blending the sidebar with the content pane.
- Refactor: Renamed unused `_cx` parameter to `cx` in the settings dialog render closure since it is now being utilized to access the theme.
2026-06-14 23:34:01 +08:00
TomZz 4bb3bee6c8 fix: ensure selection background fully covers wide characters (#24)
Rationale:
Previously, the terminal renderer generated background highlight rectangles (`LayoutRect`) with a hardcoded width of 1 cell (`cells: 1`) for every character. For full-width CJK characters (which carry the `Flags::WIDE_CHAR` flag and visually span 2 columns), this caused the selection and custom background colors to render across only half of the character's visual width.

This fix correctly interprets the terminal cell flags during layout generation: if a cell possesses `Flags::WIDE_CHAR`, it generates a background rectangle spanning 2 cells (`cells: 2`). As a result, the highlight now matches the exact bounds that the text occupies in the terminal grid, cleanly fixing the partial highlighting of Chinese text.
2026-06-14 23:19:33 +08:00
TomZz 8ab4b43825 style: update settings button icon and size 2026-06-14 19:41:55 +08:00
TomZz bbf843dde6 feat: settings dialog using Settings component #19 2026-06-14 19:34:06 +08:00
TomZz bc6af2e213 fix: close local terminal tab reliably when process exits on Windows 2026-06-14 16:31:30 +08:00
TomZz d224506fd5 fix: align sftp list item checkbox with header checkbox 2026-06-14 16:09:31 +08:00
TomZz d175135529 Merge branch 'fix-tmux' 2026-06-14 16:00:29 +08:00
TomZz c769b982b4 fix: resolve tmux mouse scroll mode recognition and alternate scroll fallback 2026-06-14 15:56:22 +08:00
TomZz ee68835972 fix: default monitoring area position when sessions.json is absent 2026-06-14 15:45:08 +08:00
TomZz 8a535fad5a fix: fallback to ssh-rsa signature for legacy OpenWrt dropbear
Resolves issue #20 where RSA 4096-bit keys failed to authenticate on OpenWrt
Dropbear servers. The previous logic exclusively tried rsa-sha2-512 and rsa-sha2-256,
which caused authentication aborts on servers only supporting legacy ssh-rsa (SHA-1)
signatures.

- Modified `private_key_with_alg` to `private_keys_with_algs` to return a list of
  supported algorithms including SHA-1 fallback for RSA keys.
- Implemented an iterative algorithm fallback loop in both SSH and SFTP connection
  authentication phases.
2026-06-14 14:22:24 +08:00
TomZz 90c07e16e1 Fix: change log timestamps and rolling filename suffix from UTC to local time 2026-06-14 14:06:57 +08:00
TomZz 33a3d624b7 Refactor: integrate comprehensive tracing logs and SSH multiplexing
This commit introduces a robust logging system and optimizes remote metric collection over SSH:

- Logging Infrastructure:
  - Replaced ad-hoc `eprintln!` and `println!` with the `tracing` crate.
  - Implemented a rolling file appender in `~/.config/ashell/log/` keeping the last 6 minutes of logs.
  - Restricted stdout logging to debug builds only, keeping release builds clean.

- SSH Multiplexing & Performance:
  - Shared `russh::client::Handle` across tasks using `Arc<Mutex<>>`.
  - Replaced the 5-second polling of new SSH connections with `SampleMetrics` over the existing channel.
  - Eliminated authentication log spam and vastly improved polling latency.
  - Optimized terminal `resize` events to only trigger when dimensions actually change, further reducing UI layout spam and backend load.

- Application & Network Auditing:
  - Log window lifecycles (open, close).
  - Log UI actions such as session connects and tab closures.
  - Detailed connection logs distinguishing between graceful EOFs, network drops, and abrupt closures.
  - Granular SFTP tracking including directory navigation, creation, batch uploads/downloads, and online file editing.
2026-06-14 13:57:50 +08:00
20 changed files with 1426 additions and 694 deletions
Generated
+86 -1
View File
@@ -303,7 +303,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.1.0"
version = "0.3.11"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -333,8 +333,10 @@ dependencies = [
"sysinfo 0.33.1",
"tar",
"thiserror 1.0.69",
"time",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"walkdir",
@@ -1650,6 +1652,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
@@ -4540,6 +4551,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -4602,6 +4619,15 @@ dependencies = [
"libc",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
@@ -5371,6 +5397,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -7298,6 +7330,12 @@ dependencies = [
"zeno",
]
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]]
name = "syn"
version = "2.0.117"
@@ -7533,6 +7571,39 @@ dependencies = [
"zune-jpeg 0.5.15",
]
[[package]]
name = "time"
version = "0.3.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca"
[[package]]
name = "time-macros"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
@@ -7807,6 +7878,19 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -7852,6 +7936,7 @@ dependencies = [
"sharded-slab",
"smallvec",
"thread_local",
"time",
"tracing",
"tracing-core",
"tracing-log",
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.1.0"
version = "0.3.11"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -34,7 +34,7 @@ tar = "0.4"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time", "time"] }
uuid = { version = "1", features = ["v4", "serde"] }
walkdir = "2"
zip = { version = "2", default-features = false, features = ["deflate"] }
@@ -42,6 +42,8 @@ image = { version = "0.25.9", default-features = false, features = ["png"] }
futures = "0.3.32"
notify = "6.1.1"
open = "5.1"
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
tracing-appender = "0.2.5"
[profile.release]
opt-level = 3
+13 -3
View File
@@ -67,7 +67,6 @@ terminal_font_size: "Terminal Font Size"
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
@@ -95,7 +94,7 @@ n_folders: "%{folders} folders"
n_files_and_folders: "%{files} files and %{folders} folders"
reset: "Reset"
reset_layout: "Reset Default Layout"
reset_layout_success: "Layout reset. Window size takes effect on restart."
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
transfers: "Transfers"
no_transfers_yet: "No transfers yet"
@@ -109,9 +108,20 @@ session: "Session"
software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to copy/paste."
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
scroll: "Scroll ↓"
clone: "Clone"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_other: "Other"
theme: "Theme"
theme_mode: "Theme Mode"
clear_transfers: "Clear History"
transfers_limit: " (Max 100 records)"
+14 -3
View File
@@ -69,7 +69,6 @@ terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
theme_management_hint: "深色/浅色模式以及主题配色由顶部栏的样式按钮统一管理。"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
@@ -97,7 +96,7 @@ n_folders: "%{folders} 个文件夹"
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
transfers: "传输记录"
no_transfers_yet: "暂无传输记录"
@@ -111,9 +110,21 @@ session: "会话"
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_other: "其它"
theme: "主题"
theme_mode: "主题模式"
clear_transfers: "清空记录"
transfers_limit: " (最多保留 100 条)"
+585 -479
View File
File diff suppressed because it is too large Load Diff
+10 -18
View File
@@ -31,7 +31,7 @@ use crate::{
session::config::{AuthMethod, ConfigStore},
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendEvent, TabKind, TerminalTab},
backend::ssh,
};
#[derive(Clone, Debug)]
@@ -217,6 +217,7 @@ pub(crate) struct Ashell {
pub(crate) selector_selection: usize,
pub(crate) workspace_panels: Entity<ResizableState>,
pub(crate) body_panels: Entity<ResizableState>,
pub(crate) is_layout_reset: bool,
pub(crate) terminal_scrollbars: HashMap<String, TerminalScrollbarHandle>,
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
@@ -398,6 +399,7 @@ impl Ashell {
selector_selection: 0,
workspace_panels,
body_panels,
is_layout_reset: false,
terminal_scrollbars: HashMap::new(),
remote_files_scroll_handle: UniformListScrollHandle::new(),
disk_scroll_handle: gpui::ScrollHandle::new(),
@@ -716,6 +718,9 @@ impl Ashell {
state: crate::terminal::TransferState::Running,
},
);
if self.transfers.len() > 100 {
self.transfers.truncate(100);
}
transfers_changed = true;
}
BackendEvent::SftpHome { tab_id, home } => {
@@ -778,30 +783,16 @@ impl Ashell {
pub(crate) fn request_active_system_snapshot(&mut self) {
let Some(ref tab_id) = self.system_tab_id.clone() else { return };
let Some(session) = (|| {
let Some(backend) = (|| {
let tab = self.tabs.iter().find(|t| t.id == *tab_id)?;
if !tab.connected { return None; }
tab.session.clone()
Some(tab.backend.clone())
})() else { return };
if self.remote_sample_in_flight {
return;
}
self.remote_sample_in_flight = true;
let events = self.events_tx.clone();
let tab_id = tab_id.clone();
self.runtime.spawn(async move {
match ssh::sample_remote_system(session).await {
Ok(snapshot) => {
let _ = events.send(BackendEvent::RemoteSystem { tab_id, snapshot });
}
Err(err) => {
let _ = events.send(BackendEvent::RemoteSystemUnavailable {
tab_id,
reason: format!("remote metrics unavailable: {err:#}"),
});
}
}
});
backend.send(crate::terminal::BackendCommand::SampleMetrics);
}
pub(crate) fn terminal_ime_bounds_for_range(
@@ -827,3 +818,4 @@ impl Ashell {
))
}
}
+108
View File
@@ -4,6 +4,106 @@ use gpui_component::Root;
use crate::session::config::ConfigStore;
use crate::Ashell;
struct LocalMinutelyRoller {
dir: std::path::PathBuf,
prefix: String,
current_minute: u32,
file: Option<std::fs::File>,
}
impl LocalMinutelyRoller {
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
Self { dir, prefix, current_minute: 60, file: None }
}
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
use chrono::Timelike;
let minute = now.minute();
if self.current_minute != minute || self.file.is_none() {
let filename = format!("{}-{}.log", self.prefix, now.format("%Y-%m-%d-%H-%M"));
let path = self.dir.join(filename);
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
self.file = Some(file);
self.current_minute = minute;
// Cleanup old files keeping last 6
if let Ok(entries) = std::fs::read_dir(&self.dir) {
let mut files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
.collect();
files.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH));
if files.len() > 6 {
for file in files.iter().take(files.len() - 6) {
let _ = std::fs::remove_file(file.path());
}
}
}
}
Ok(())
}
}
impl std::io::Write for LocalMinutelyRoller {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let now = chrono::Local::now();
let _ = self.rollover(now);
if let Some(f) = &mut self.file {
f.write(buf)
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> std::io::Result<()> {
if let Some(f) = &mut self.file {
f.flush()
} else {
Ok(())
}
}
}
pub(crate) fn init_logging() {
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
let log_dir = directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
std::fs::create_dir_all(&log_dir).ok();
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
std::mem::forget(_guard);
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let stdout_layer = if cfg!(debug_assertions) {
Some(tracing_subscriber::fmt::layer().with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()).with_target(true))
} else {
None
};
let file_layer = tracing_subscriber::fmt::layer()
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true);
tracing_subscriber::registry()
.with(env_filter)
.with(stdout_layer)
.with(file_layer)
.init();
}
#[cfg(target_os = "macos")]
pub(crate) fn sync_macos_launch_environment() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
@@ -117,9 +217,17 @@ pub(crate) fn open_main_window(cx: &mut App) {
gpui_component::Theme::sync_system_appearance(Some(window), cx);
let view = cx.new(|cx| Ashell::new(window, cx));
tracing::info!("[ui] main application window opened");
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
return true;
}
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let current_bounds = window.window_bounds();
let saved_bounds = match current_bounds {
+2 -92
View File
@@ -1,11 +1,8 @@
use anyhow::{Context as _, Result};
use gpui::{Anchor, App, Context, IntoElement, SharedString, Window, px};
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{
ActiveTheme as _, IconName, Sizable as _, Theme, ThemeMode, ThemeRegistry,
button::{Button, ButtonVariants as _},
menu::{DropdownMenu as _, PopupMenuItem},
ActiveTheme as _, Theme, ThemeMode, ThemeRegistry,
};
use rust_i18n::t;
use crate::Ashell;
@@ -147,92 +144,5 @@ impl Ashell {
}
}
pub(crate) fn theme_dropdown(&self, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let themes = ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.cloned()
.collect::<Vec<_>>();
let light_themes = themes
.iter()
.filter(|theme| !theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let dark_themes = themes
.iter()
.filter(|theme| theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let follow_system = self.follow_system_theme;
let is_dark_mode = cx.theme().mode.is_dark();
let light_theme_name = self.light_theme_name.clone();
let dark_theme_name = self.dark_theme_name.clone();
let icon = if follow_system {
IconName::Sun
} else if is_dark_mode {
IconName::Moon
} else {
IconName::Sun
};
Button::new("theme-dropdown")
.ghost()
.small()
.icon(icon)
.dropdown_menu_with_anchor(Anchor::BottomRight, move |mut menu, window, _| {
menu = menu
.min_w(220.)
.item(
PopupMenuItem::new(t!("follow_system"))
.checked(follow_system)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.set_follow_system_theme(true, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_light_mode"))
.checked(!follow_system && !is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Light, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_dark_mode"))
.checked(!follow_system && is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Dark, window, cx)
})),
)
.separator()
.label(t!("light_theme").to_string());
for theme_name in light_themes.clone() {
let checked = theme_name == light_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu = menu.separator();
menu = menu.label(t!("dark_theme").to_string());
for theme_name in dark_themes.clone() {
let checked = theme_name == dark_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu
})
}
}
+13 -5
View File
@@ -431,7 +431,8 @@ impl Ashell {
.w_full()
.h(px(28.))
.items_center()
.gap_2()
.px_3()
.gap_2()
.bg(bg)
.hover(|style| style.bg(theme.muted.opacity(0.8)))
.border_b_1()
@@ -1202,13 +1203,12 @@ impl Ashell {
.child(
Button::new("sidebar-settings")
.ghost()
.small()
.icon(IconName::Settings2)
.icon(IconName::Settings)
.on_click(cx.listener(|this, _, window, cx| {
this.show_settings_dialog(window, cx)
})),
)
.child(self.theme_dropdown(cx)),
)
.child(
div()
@@ -1470,7 +1470,15 @@ impl Ashell {
.h(px(32.))
.bg(dot_color),
)
.label(label)
.child(
div()
.when(ix == selected, |this| {
this.font_weight(FontWeight::BOLD)
.text_color(cx.theme().primary)
.text_base()
})
.child(label),
)
.on_click(cx.listener(
move |this, _, window, cx| {
this.activate_group(
+30 -17
View File
@@ -94,27 +94,40 @@ pub fn spawn_local_terminal(
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
while let Ok(command) = cmd_rx.recv() {
match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
loop {
match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(command) => match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
});
break;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
tab_id: write_tab,
reason: format!("local shell exited: {status}"),
});
break;
return;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = child.kill();
+95 -26
View File
@@ -49,10 +49,12 @@ pub fn spawn_ssh_terminal(
BackendTx::Ssh(cmd_tx)
}
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
let (events_tx, _events_rx) = std::sync::mpsc::channel();
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
async fn sample_remote_system_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<SystemSnapshot> {
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open metrics session")?;
@@ -72,9 +74,6 @@ pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "metrics done", "")
.await;
let output = String::from_utf8_lossy(&stdout);
remote_snapshot_from_kv(&output)
}
@@ -95,9 +94,11 @@ async fn run_ssh(
),
});
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
let handle = Arc::new(tokio::sync::Mutex::new(connect_and_authenticate(&tab_id, &session, &events).await?));
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open session")?;
@@ -124,6 +125,7 @@ async fn run_ssh(
match command {
Some(BackendCommand::Input(bytes)) => {
if let Err(err) = channel.data(bytes.as_slice()).await {
tracing::error!("[ssh] write error on tab {}: {}", tab_id, err);
exit_reason = format!("ssh write error: {err}");
break;
}
@@ -131,7 +133,29 @@ async fn run_ssh(
Some(BackendCommand::Resize { cols, rows }) => {
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
}
Some(BackendCommand::SampleMetrics) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match sample_remote_system_with_handle(handle_clone).await {
Ok(snapshot) => {
let _ = events_clone.send(BackendEvent::RemoteSystem {
tab_id: tab_id_clone,
snapshot,
});
}
Err(err) => {
let _ = events_clone.send(BackendEvent::RemoteSystemUnavailable {
tab_id: tab_id_clone,
reason: format!("remote metrics unavailable: {err:#}"),
});
}
}
});
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
exit_reason = "ssh session closed".to_string();
break;
@@ -151,16 +175,20 @@ async fn run_ssh(
}
Some(ChannelMsg::Close) => {
if is_graceful_close {
tracing::info!("[ssh] session gracefully closed by server for tab {}", tab_id);
exit_reason = "ssh session closed".to_string();
} else {
tracing::warn!("[ssh] connection abruptly closed by server for tab {}", tab_id);
exit_reason = "ssh connection lost (abrupt close)".to_string();
}
break;
}
None => {
if is_graceful_close {
tracing::info!("[ssh] network stream ended gracefully for tab {}", tab_id);
exit_reason = "ssh session closed".to_string();
} else {
tracing::warn!("[ssh] network drop detected for tab {}", tab_id);
exit_reason = "ssh connection lost (network drop)".to_string();
}
break;
@@ -172,6 +200,8 @@ async fn run_ssh(
}
let _ = handle
.lock()
.await
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
let _ = events.send(BackendEvent::Closed {
@@ -193,6 +223,7 @@ async fn connect_and_authenticate(
..Default::default()
});
let addr = format!("{}:{}", session.host, session.port);
tracing::info!("[ssh] initiating tcp connection to {} (user: {})", addr, session.user);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("opening tcp connection to {addr}"),
@@ -200,9 +231,12 @@ async fn connect_and_authenticate(
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
.await
.with_context(|| format!("connect {addr} failed"))?;
tracing::debug!("[ssh] tcp connected to {}", addr);
let authed = match session.auth {
AuthMethod::Password => {
tracing::info!("[ssh] sending password authentication for {}@{}", session.user, addr);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
@@ -217,6 +251,7 @@ async fn connect_and_authenticate(
}
AuthMethod::Key => {
let source = key_source_label(session);
tracing::info!("[ssh] sending key 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}"),
@@ -227,20 +262,36 @@ async fn connect_and_authenticate(
tab_id: tab_id.to_string(),
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
});
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.with_context(|| {
format!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user, session.host, session.port, source, 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) => {
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
));
}
success
}
};
if !authed {
tracing::warn!("[ssh] authentication failed for {}@{}", session.user, addr);
let _ = handle
.disconnect(Disconnect::ByApplication, "auth failed", "")
.await;
@@ -262,6 +313,8 @@ async fn connect_and_authenticate(
));
}
tracing::info!("[ssh] authentication successful for {}@{}", session.user, addr);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
@@ -302,16 +355,31 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
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 {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
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 {
@@ -438,6 +506,7 @@ echo "NET_RX=0"
echo "NET_TX=0"
'"#;
#[derive(Clone)]
struct ClientHandler;
#[async_trait]
+1 -6
View File
@@ -20,12 +20,7 @@ pub(crate) use app::{
fn main() {
app::startup::sync_macos_launch_environment();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
app::startup::init_logging();
#[cfg(target_os = "macos")]
let app = gpui_platform::application()
+7 -3
View File
@@ -140,11 +140,11 @@ fn default_locale() -> String {
}
fn default_terminal_font_size() -> f32 {
13.0
18.0
}
fn default_ui_font_size() -> f32 {
12.0
14.0
}
pub fn default_ui_font_family() -> String {
@@ -247,7 +247,11 @@ impl ConfigStore {
}
pub fn monitoring_position(&self) -> &str {
&self.cache.monitoring_position
if self.cache.monitoring_position.is_empty() {
"Sidebar"
} else {
&self.cache.monitoring_position
}
}
pub fn set_monitoring_position(&mut self, pos: &str) {
+8 -5
View File
@@ -57,6 +57,7 @@ impl Ashell {
}
pub(crate) fn connect_ssh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
tracing::info!("[ui] user initiating new ssh connection from form");
let session_name = self.session_name_input.read(cx).value().trim().to_string();
let host = self.host_input.read(cx).value().trim().to_string();
let port = self
@@ -284,10 +285,10 @@ impl Ashell {
self.config.set_layout_state(None, None, None);
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.status = t!("reset_layout_success").into();
cx.notify();
}
@@ -297,6 +298,7 @@ impl Ashell {
}
pub(crate) fn connect_saved_session(&mut self, session_id: String, cx: &mut Context<Self>) {
tracing::info!("[ui] user clicked to connect saved session '{}'", session_id);
let Some(session) = self.config.get(&session_id).cloned() else {
self.status = "saved session not found".into();
cx.notify();
@@ -390,6 +392,7 @@ impl Ashell {
}
pub(crate) fn open_ssh_session(&mut self, session: Session, cx: &mut Context<Self>) {
tracing::info!("[session] opening ssh tab for session '{}' ({}@{})", session.name, session.user, session.host);
let id = Uuid::new_v4().to_string();
let backend = ssh::spawn_ssh_terminal(
self.runtime.handle(),
@@ -600,7 +603,7 @@ impl Ashell {
let group_ix = self.tab_groups.iter().position(|g| g.pane_root.contains(&id));
let Some(ref group) = group_ix.map(|i| self.tab_groups[i].clone()) else {
// Fallback: find and close individual tab
eprintln!("[handle_tab_close] no group found for tab '{}', closing individually", id);
tracing::info!("[handle_tab_close] no group found for tab '{}', closing individually", id);
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs.remove(ix);
@@ -611,7 +614,7 @@ impl Ashell {
let pane_ids = group.pane_root.tab_ids();
let pane_ids_str: Vec<&str> = pane_ids.iter().map(|s| *s).collect();
let is_group_close = pane_ids.len() <= 1;
eprintln!(
tracing::info!(
"[handle_tab_close] id='{}' group_panes={:?} is_group_close={}",
id, pane_ids_str, is_group_close
);
@@ -842,7 +845,7 @@ impl Ashell {
}
pub(crate) fn split_current_pane(&mut self, direction: &str, cx: &mut Context<Self>) {
eprintln!(
tracing::info!(
"[split] direction={} pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
direction,
self.pane_root,
@@ -949,7 +952,7 @@ impl Ashell {
self.focused_pane_path = new_full_path;
self.active_tab = Some(new_id);
self.status = "pane split".into();
eprintln!(
tracing::info!(
"[split] DONE: pane_root={:?} focused_path={:?} active_tab={:?} tabs={}",
self.pane_root,
self.focused_pane_path,
+49 -14
View File
@@ -642,6 +642,8 @@ async fn run_sftp(
} else {
path.clone()
};
tracing::info!("[sftp] creating directory: '{}'", actual_path);
match sftp.create_dir(&actual_path).await {
Ok(_) => {
@@ -666,6 +668,7 @@ async fn run_sftp(
}
}
SftpCommand::DeletePaths(paths) => {
tracing::info!("[sftp] batch deleting {} paths", paths.len());
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("deleting_paths", count = paths.len()).to_string(),
@@ -797,11 +800,28 @@ async fn connect_and_authenticate(
.context("password authentication failed")?,
AuthMethod::Key => {
let keypair = load_session_private_key(session)?;
let key = private_key_with_alg(keypair).context("invalid private key")?;
handle
.authenticate_publickey(&session.user, key)
.await
.context("public key authentication failed")?
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));
}
success
}
};
@@ -853,16 +873,31 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
Err(anyhow!(errors.join("; ")))
}
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
let hash_alg = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha512)
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 {
None
};
Ok(
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
)
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 {
+13 -3
View File
@@ -40,6 +40,7 @@ impl Ashell {
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] navigating to directory: '{}'", path);
handle.list_dir(path.clone());
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path;
@@ -134,6 +135,7 @@ impl Ashell {
return;
};
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path);
handle.edit_file(menu.remote_path);
}
cx.notify();
@@ -158,7 +160,9 @@ impl Ashell {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
handle.download(remote_path, folder.to_string_lossy().to_string());
let local_path = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating download of '{}' to '{}'", remote_path, local_path);
handle.download(remote_path, local_path);
}
}
Ok(Err(err)) => {
@@ -192,7 +196,9 @@ impl Ashell {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(file) = paths.pop() {
handle.upload_paths(vec![file.to_string_lossy().to_string()], remote_dir);
let local_path = file.to_string_lossy().to_string();
tracing::info!("[sftp] initiating upload of file '{}' to '{}'", local_path, remote_dir);
handle.upload_paths(vec![local_path], remote_dir);
}
}
Ok(Err(err)) => {
@@ -226,7 +232,9 @@ impl Ashell {
match path_prompt.await {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
handle.upload_paths(vec![folder.to_string_lossy().to_string()], remote_dir);
let local_path = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating upload of folder '{}' to '{}'", local_path, remote_dir);
handle.upload_paths(vec![local_path], remote_dir);
}
}
Ok(Err(err)) => {
@@ -291,6 +299,7 @@ impl Ashell {
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
if let Some(folder) = paths.pop() {
let local_dir = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating batch download of {} entries to '{}'", selected.len(), local_dir);
for remote in selected {
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
remote,
@@ -317,6 +326,7 @@ impl Ashell {
}
if let Some(sftp) = self.active_sftp() {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] initiating batch upload of {} files to '{}'", paths.len(), sftp.current_path);
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
locals: paths,
remote_dir: sftp.current_path.clone(),
+251
View File
@@ -0,0 +1,251 @@
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
pub fn is_custom_block_supported(c: char) -> bool {
match c as u32 {
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534 | 0x253C => true, // Light lines
0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B | 0x254B => true, // Heavy lines
0xE0B0..=0xE0B6 => true, // Powerline
_ => false,
}
}
pub fn paint_custom_block(
window: &mut Window,
c: char,
raw_x: f32,
raw_y: f32,
raw_w: f32,
raw_h: f32,
color: Hsla,
) -> bool {
let scale = window.scale_factor();
let x = px((raw_x * scale).round() / scale);
let y = px((raw_y * scale).round() / scale);
let w = px(((raw_x + raw_w) * scale).round() / scale) - x;
let h = px(((raw_y + raw_h) * scale).round() / scale) - y;
let mut painted = false;
// Helper to paint a quad snapping its true physical boundaries to integer physical pixels
let mut paint_quad = |qx: Pixels, qy: Pixels, qw: Pixels, qh: Pixels| {
let l = px((qx.as_f32() * scale).round() / scale);
let t = px((qy.as_f32() * scale).round() / scale);
let r = px(((qx + qw).as_f32() * scale).round() / scale);
let b = px(((qy + qh).as_f32() * scale).round() / scale);
window.paint_quad(fill(Bounds::new(point(l, t), size(r - l, b - t)), color));
painted = true;
};
let px_1 = px(1.0);
let px_2 = px(2.0);
// Box drawing thickness
let light = (w * 0.1).max(px_1);
let heavy = (w * 0.2).max(px_2);
let cx = x + w / 2.0;
let cy = y + h / 2.0;
match c as u32 {
// --- Block Elements (U+2580..=U+259F) ---
0x2580 => paint_quad(x, y, w, h / 2.0),
0x2581 => paint_quad(x, y + h * 7.0 / 8.0, w, h / 8.0),
0x2582 => paint_quad(x, y + h * 3.0 / 4.0, w, h / 4.0),
0x2583 => paint_quad(x, y + h * 5.0 / 8.0, w, h * 3.0 / 8.0),
0x2584 => paint_quad(x, y + h / 2.0, w, h / 2.0),
0x2585 => paint_quad(x, y + h * 3.0 / 8.0, w, h * 5.0 / 8.0),
0x2586 => paint_quad(x, y + h / 4.0, w, h * 3.0 / 4.0),
0x2587 => paint_quad(x, y + h / 8.0, w, h * 7.0 / 8.0),
0x2588 => paint_quad(x, y, w, h),
0x2589 => paint_quad(x, y, w * 7.0 / 8.0, h),
0x258A => paint_quad(x, y, w * 3.0 / 4.0, h),
0x258B => paint_quad(x, y, w * 5.0 / 8.0, h),
0x258C => paint_quad(x, y, w / 2.0, h),
0x258D => paint_quad(x, y, w * 3.0 / 8.0, h),
0x258E => paint_quad(x, y, w / 4.0, h),
0x258F => paint_quad(x, y, w / 8.0, h),
0x2590 => paint_quad(x + w / 2.0, y, w / 2.0, h),
0x2594 => paint_quad(x, y, w, h / 8.0),
0x2595 => paint_quad(x + w * 7.0 / 8.0, y, w / 8.0, h),
0x2596 => paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0),
0x2597 => paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0),
0x2598 => paint_quad(x, y, w / 2.0, h / 2.0),
0x2599 => {
paint_quad(x, y, w / 2.0, h);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259A => {
paint_quad(x, y, w / 2.0, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259B => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259C => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259D => paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0),
0x259E => {
paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259F => {
paint_quad(x + w / 2.0, y, w / 2.0, h);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
// --- Basic Box Drawing (U+2500..=U+257F) ---
// Light lines
0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal
0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical
0x250C => { // Down + Right
paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0);
paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2510 => { // Down + Left
paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2514 => { // Up + Right
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2518 => { // Up + Left
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x251C => { // Vertical + Right
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(cx - light / 2.0, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2524 => { // Vertical + Left
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x252C => { // Horizontal + Down
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, cy - light / 2.0, light, h / 2.0 + light / 2.0);
}
0x2534 => { // Horizontal + Up
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
}
0x253C => { // Vertical + Horizontal (Cross)
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h);
}
// Heavy lines
0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal
0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical
0x250F => { // Heavy Down + Right
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2513 => { // Heavy Down + Left
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2517 => { // Heavy Up + Right
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x251B => { // Heavy Up + Left
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2523 => { // Heavy Vertical + Right
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x252B => { // Heavy Vertical + Left
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2533 => { // Heavy Horizontal + Down
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, cy - heavy / 2.0, heavy, h / 2.0 + heavy / 2.0);
}
0x253B => { // Heavy Horizontal + Up
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
}
0x254B => { // Heavy Vertical + Horizontal (Cross)
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h);
}
// --- Powerline ---
0xE0B0 => { // Rightward Solid Arrow
let mut path = Path::new(point(x, y));
path.line_to(point(x + w, y + h / 2.0));
path.line_to(point(x, y + h));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B2 => { // Leftward Solid Arrow
let mut path = Path::new(point(x + w, y));
path.line_to(point(x, y + h / 2.0));
path.line_to(point(x + w, y + h));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
0xE0B1 => { // Rightward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x, y));
p.line_to(point(x + w, y + h / 2.0));
p.line_to(point(x + w - t, y + h / 2.0));
p.line_to(point(x, y + t));
p.line_to(point(x, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x, y + h));
p2.line_to(point(x + w, y + h / 2.0));
p2.line_to(point(x + w - t, y + h / 2.0));
p2.line_to(point(x, y + h - t));
p2.line_to(point(x, y + h));
window.paint_path(p2, color);
painted = true;
}
0xE0B3 => { // Leftward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x + w, y));
p.line_to(point(x, y + h / 2.0));
p.line_to(point(x + t, y + h / 2.0));
p.line_to(point(x + w, y + t));
p.line_to(point(x + w, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x + w, y + h));
p2.line_to(point(x, y + h / 2.0));
p2.line_to(point(x + t, y + h / 2.0));
p2.line_to(point(x + w, y + h - t));
p2.line_to(point(x + w, y + h));
window.paint_path(p2, color);
painted = true;
}
// Half arches (Powerline rounded)
0xE0B4 => { // Rightward Solid Semicircle
let mut path = Path::new(point(x, y));
path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B6 => { // Leftward Solid Semicircle
let mut path = Path::new(point(x + w, y));
path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
_ => {}
}
painted
}
+49 -4
View File
@@ -12,6 +12,7 @@ use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::{RenderSnapshot, ViewportSelection};
use crate::terminal::custom_blocks::{paint_custom_block, is_custom_block_supported};
#[derive(Clone, Copy)]
struct TerminalMetrics {
@@ -138,9 +139,19 @@ pub struct PrepaintState {
metrics: TerminalMetrics,
rects: Vec<LayoutRect>,
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
}
#[derive(Clone)]
struct LayoutCustomBlock {
c: char,
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
struct TerminalInputHandler {
view: Entity<Ashell>,
element_bounds: Bounds<Pixels>,
@@ -331,9 +342,10 @@ impl TerminalElement {
}
}
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
for render_cell in &self.snapshot.cells {
@@ -352,7 +364,7 @@ impl TerminalElement {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: 1,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
@@ -371,6 +383,24 @@ impl TerminalElement {
}
let style = self.cell_run_style(cell, cx);
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
if is_custom_block {
if let Some(run) = current_run.take() {
runs.push(run);
}
custom_blocks.push(LayoutCustomBlock {
c: cell.c,
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
color: style.color,
});
continue;
}
if let Some(run) = current_run.as_mut() {
if run.can_append(&style, render_cell.row, render_cell.col) {
run.append(cell.c, cell.zerowidth());
@@ -402,7 +432,7 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs)
(merge_rects(rects), runs, custom_blocks)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
@@ -458,7 +488,7 @@ impl Element for TerminalElement {
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs) = self.layout_grid(cx);
let (rects, runs, custom_blocks) = self.layout_grid(cx);
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -467,6 +497,7 @@ impl Element for TerminalElement {
},
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
}
}
@@ -489,6 +520,20 @@ impl Element for TerminalElement {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
}
for block in &prepaint.custom_blocks {
let x = prepaint.bounds.origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = prepaint.bounds.origin.y.as_f32() + block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
block.c,
x,
y,
prepaint.metrics.cell_width.as_f32() * block.cells as f32,
prepaint.metrics.line_height.as_f32(),
block.color,
);
}
window.handle_input(
&self.focus_handle,
TerminalInputHandler {
+75 -9
View File
@@ -394,19 +394,85 @@ impl Ashell {
window: &mut Window,
cx: &mut Context<Self>,
) {
let delta_lines = match event.delta {
ScrollDelta::Lines(point) => point.y.round() as i32,
ScrollDelta::Pixels(point) => {
(point.y.as_f32() / self.terminal_line_height()).round() as i32
}
};
if delta_lines == 0 {
return;
}
let Some(active_id) = self.active_tab.clone() else {
return;
};
// Get coordinates before mutably borrowing tabs
let grid_point = self.terminal_grid_point_and_side(event.position);
let line_height = self.terminal_line_height();
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
let delta_lines = match event.delta {
ScrollDelta::Lines(point) => point.y.round() as i32,
ScrollDelta::Pixels(point) => {
tab.scroll_pixel_y += point.y.as_f32();
let lines = (tab.scroll_pixel_y / line_height).trunc() as i32;
tab.scroll_pixel_y -= (lines as f32) * line_height;
lines
}
};
if delta_lines == 0 {
return;
}
let mode = tab.term.mode();
let is_mouse_tracking = mode.intersects(
alacritty_terminal::term::TermMode::MOUSE_REPORT_CLICK
| alacritty_terminal::term::TermMode::MOUSE_MOTION
| alacritty_terminal::term::TermMode::MOUSE_DRAG,
);
let is_alternate_scroll = mode.contains(
alacritty_terminal::term::TermMode::ALT_SCREEN
| alacritty_terminal::term::TermMode::ALTERNATE_SCROLL,
);
if is_mouse_tracking {
if let Some((row, col, _)) = grid_point {
let sgr = mode.contains(alacritty_terminal::term::TermMode::SGR_MOUSE);
let button = if delta_lines > 0 { 64 } else { 65 };
let times = delta_lines.abs();
let mut bytes = Vec::new();
for _ in 0..times {
if sgr {
bytes.extend_from_slice(
format!("\x1b[<{};{};{}M", button, col + 1, row + 1).as_bytes(),
);
} else {
if col < 223 && row < 223 {
bytes.extend_from_slice(b"\x1b[M");
bytes.push(button as u8 + 32);
bytes.push(col as u8 + 33);
bytes.push(row as u8 + 33);
}
}
}
if !bytes.is_empty() {
tab.backend.send(crate::terminal::BackendCommand::Input(bytes));
}
}
window.prevent_default();
cx.stop_propagation();
return;
} else if is_alternate_scroll {
let times = delta_lines.abs();
let code = if delta_lines > 0 { b'A' } else { b'B' };
let mut bytes = Vec::with_capacity((times * 3) as usize);
for _ in 0..times {
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
}
if !bytes.is_empty() {
tab.backend.send(crate::terminal::BackendCommand::Input(bytes));
}
window.prevent_default();
cx.stop_propagation();
return;
}
tab.scroll_history(delta_lines);
window.prevent_default();
cx.stop_propagation();
+13 -4
View File
@@ -1,5 +1,6 @@
pub mod element;
pub mod input;
pub mod custom_blocks;
use std::sync::mpsc::Sender;
@@ -27,6 +28,7 @@ pub enum TabKind {
pub enum BackendCommand {
Input(Vec<u8>),
Resize { cols: u16, rows: u16 },
SampleMetrics,
Close,
}
@@ -121,6 +123,7 @@ pub struct TerminalTab {
cols: u16,
rows: u16,
pub backend: BackendTx,
pub scroll_pixel_y: f32,
}
#[derive(Clone, Copy)]
@@ -227,6 +230,7 @@ impl TerminalTab {
cols: 100,
rows: 30,
backend,
scroll_pixel_y: 0.0,
}
}
@@ -235,10 +239,15 @@ impl TerminalTab {
}
pub fn resize(&mut self, cols: u16, rows: u16) {
self.cols = cols.max(1);
self.rows = rows.max(1);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
let new_cols = cols.max(1);
let new_rows = rows.max(1);
if self.cols != new_cols || self.rows != new_rows {
self.cols = new_cols;
self.rows = new_rows;
tracing::info!("[ui] terminal resized to {}x{} (cols x rows)", self.cols, self.rows);
self.term.resize(TerminalSize::new(self.cols, self.rows));
self.backend.send(BackendCommand::Resize { cols, rows });
}
}
pub fn cursor_state(&self) -> Option<CursorState> {