Compare commits

...
10 Commits
Author SHA1 Message Date
TomZz b45364331d fix: restore full terminal width in split panes
Use the outer terminal panel bounds for PTY resizing while keeping per-pane bounds for focus and input handling. This prevents split panes from being resized a second time to half width.
2026-06-16 04:38:01 +08:00
TomZz 2d92f0ceee chore(release): v0.4.2
Bump the project version to v0.4.2 and include the updated README release notes in both Chinese and English.
2026-06-16 04:27:50 +08:00
TomZz 162a245a8f feat: improve theme defaults, config compatibility, and SSH key passphrase support
- default follow_system_theme to true and remove empty theme-name fallback override\n- add backward compatibility for v0.3.11 transfer state values during config load\n- preserve old config files by backing up parse failures instead of silently overwriting them\n- add optional SSH key passphrase input to the new SSH form and persist it in sessions\n- use stored passphrases when loading private keys for SSH and SFTP connections\n- make terminal size sync prefer measured bounds to reduce unused space at the bottom
2026-06-16 04:16:13 +08:00
TomZz 68bf152792 chore(release): v0.4.1 2026-06-16 01:54:35 +08:00
TomZz 13b9433c90 fix: fix settings layout collapse caused by keybind wrapper 2026-06-16 01:53:55 +08:00
TomZz 445e474fe8 chore: bump version to v0.4.0 2026-06-16 01:06:43 +08:00
TomZz 4c7c06f391 fix: fix interrupted message position, handle zombie transfers on restart, fix upload status text 2026-06-16 01:06:43 +08:00
TomZz e4fbd65a31 fix: show tab title in transfer history 2026-06-15 20:50:12 +08:00
TomZz bd376935f4 feat: add keybinding management
解决了 #18。

验证:

- cargo fmt

- cargo check

- git diff --check
2026-06-15 20:32:05 +08:00
TomZz 16761c1bf5 ci: add CI workflow to pre-populate rust cache on main branch 2026-06-15 01:29:03 +08:00
23 changed files with 2377 additions and 1011 deletions
+58
View File
@@ -0,0 +1,58 @@
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
name: windows-x86_64
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
steps:
- uses: actions/checkout@v4
# GPUI needs these system libs on Linux to compile.
- name: Install Linux GUI dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential pkg-config cmake \
libfontconfig1-dev libfreetype6-dev \
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
libgl1-mesa-dev libegl1-mesa-dev libgtk-3-dev \
libudev-dev
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build (release)
# We build in release mode so that the release workflow can reuse the exact artifacts
run: cargo build --release --target ${{ matrix.target }}
Generated
+1 -1
View File
@@ -303,7 +303,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.3.11"
version = "0.4.2"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.3.11"
version = "0.4.2"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
+9 -8
View File
@@ -8,15 +8,15 @@
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
## 🚀 v0.3 Massive Update!
## 🚀 v0.4 Major Upgrades
The brand-new v0.3 introduces comprehensive upgrades to both architecture and user experience:
- ✨ **Global Real-Time Font Settings** (Thanks to [@ancion](https://github.com/ancion) for [PR #6](https://github.com/rust-kotlin/ashell/pull/6)): Customize your global UI fonts and terminal rendering fonts independently. Adjust text size in real-time with automatic persistence.
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress display.
- ✨ **Persistent Workspace State**: Your sidebar width adjustments, terminal themes, font settings, and file visibility preferences are completely restored upon your next launch!
- ✨ **Intelligent SSH Disconnect/Reconnect**: Greatly enhanced SSH connection resilience. Provides a beautiful and localized overlay for one-click re-connection on unexpected disconnections.
- ✨ **Hot-Swappable i18n System**: Deeply optimized internationalization. Switch instantly between English and Simplified Chinese without restarting the application.
- ✨ **Smart Right-Click Actions**: Added intelligent right-click copy and paste for the terminal, featuring a dedicated settings toggle and OS-native keyboard shortcut hints.
v0.4 builds on the v0.3 foundation and focuses on more capable workspace operations and a smoother daily workflow:
- ✨ **Keybinding Management**: View and edit common shortcuts from the settings UI with conflict hints.
- ✨ **Settings Page Polish**: A more compact and clearer settings experience with improved layout and interaction flow.
- ✨ **Multi-Pane Tabs with a tmux-like Workflow**: A single tab can now host multiple panes, with split, focus, and switching actions for a tmux-inspired experience.
- ✨ **Transfer History Improvements**: The transfer history panel now presents richer task details, making upload and download activity easier to review.
- ✨ **SSH Passphrase Support**: Private keys can now store a passphrase, and SSH connections will use it automatically.
- ✨ **Terminal Rendering Enhancements**: Terminal rendering now handles Block Elements and similar custom glyphs more completely.
## Download
@@ -43,6 +43,7 @@ The current version provides a fully-featured GPUI-native workspace:
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
- **v0.3 Core Enhancements:** Global font and font-size controls, concurrent SFTP transfers, persistent layout state, disconnect awareness, hot-swappable i18n, and smart terminal right-click copy/paste.
## Run
+9 -8
View File
@@ -8,15 +8,15 @@
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
## 🚀 v0.3 版本重磅更新!
## 🚀 v0.4 版本重要升级
全新发布的 v0.3 迎来了架构和体验的全面升级:
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能力和更顺手的日常体验:
- ✨ **键位管理功能**:支持在设置中可视化查看和修改常用快捷键,并提供冲突提示。
- ✨ **设置页优化**:设置页的布局和交互更加紧凑顺手,整体信息层级更清晰。
- ✨ **Tab 内多 Pane 与类 tmux 体验**:一个 tab 内现在可以管理多个 pane,支持分屏、聚焦和切换,提供更接近 tmux 的工作流。
- ✨ **传输历史增强**:传输历史面板展示更完整的任务信息,便于回溯上传和下载过程。
- ✨ **SSH passphrase 支持**:现在可以为私钥填写 passphrase,并在连接时自动使用保存的口令。
- ✨ **终端显示优化**:终端渲染进一步增强,支持 Block Elements 等自定义图形字符的更完整显示。
## 下载
@@ -43,6 +43,7 @@ sudo xattr -cr /Applications/ashell.app
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
- **v0.3 核心增强**:全局字体与字号可调,支持 SFTP 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
## 运行
+19
View File
@@ -103,7 +103,10 @@ paused: "Paused"
completed: "Completed"
failed: "Failed"
cancelled: "Cancelled"
interrupted: "Interrupted"
session: "Session"
zombie: "Zombie"
zombie_reason: "This transfer was interrupted because the application exited. The transfer task no longer exists."
software_builtin: "Built-in"
retry: "Retry"
@@ -125,3 +128,19 @@ theme: "Theme"
theme_mode: "Theme Mode"
clear_transfers: "Clear History"
transfers_limit: " (Max 100 records)"
press_new_key: "Press new key..."
settings_open_settings: "Open Settings"
settings_open_session: "Open Session"
settings_new_ssh: "New SSH"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_focus_pane_left: "Focus Pane Left"
settings_focus_pane_right: "Focus Pane Right"
settings_focus_pane_up: "Focus Pane Up"
settings_focus_pane_down: "Focus Pane Down"
settings_split_pane_left: "Split Pane Left"
settings_split_pane_right: "Split Pane Right"
settings_split_pane_up: "Split Pane Up"
settings_split_pane_down: "Split Pane Down"
settings_close_pane: "Close Pane"
keybind_conflict: '"%{key}" is already used by "%{action}", please choose another key'
+19
View File
@@ -105,7 +105,10 @@ paused: "已暂停"
completed: "已完成"
failed: "失败"
cancelled: "已取消"
interrupted: "已中断"
session: "会话"
zombie: "僵尸任务"
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
software_builtin: "软件内置"
retry: "重试"
@@ -128,3 +131,19 @@ theme: "主题"
theme_mode: "主题模式"
clear_transfers: "清空记录"
transfers_limit: " (最多保留 100 条)"
press_new_key: "请按下新的快捷键..."
settings_open_settings: "打开设置"
settings_open_session: "打开会话"
settings_new_ssh: "新建 SSH"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
settings_focus_pane_left: "向左切换焦点"
settings_focus_pane_right: "向右切换焦点"
settings_focus_pane_up: "向上切换焦点"
settings_focus_pane_down: "向下切换焦点"
settings_split_pane_left: "向左拆分面板"
settings_split_pane_right: "向右拆分面板"
settings_split_pane_up: "向上拆分面板"
settings_split_pane_down: "向下拆分面板"
settings_close_pane: "关闭当前面板"
keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键"
+501 -348
View File
@@ -1,7 +1,7 @@
use gpui::{
Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _,
MouseButton, ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _,
Window, div, prelude::FluentBuilder as _, px, rems,
Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton,
ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div,
prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, IconName, Sizable as _, WindowExt as _,
@@ -17,11 +17,7 @@ use gpui_component::{
};
use rust_i18n::t;
use crate::{
Ashell,
session::config::AuthMethod,
system::format_bytes,
};
use crate::{Ashell, session::config::AuthMethod, system::format_bytes};
impl Ashell {
pub(crate) fn show_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -34,6 +30,7 @@ impl Ashell {
let password_input = self.password_input.clone();
let key_path_input = self.key_path_input.clone();
let key_inline_input = self.key_inline_input.clone();
let passphrase_input = self.passphrase_input.clone();
window.open_dialog(cx, move |dialog: Dialog, _window, _cx| {
dialog
@@ -49,6 +46,7 @@ impl Ashell {
let password_input = password_input.clone();
let key_path_input = key_path_input.clone();
let key_inline_input = key_inline_input.clone();
let passphrase_input = passphrase_input.clone();
move |content, window, cx| {
let is_password = view.read(cx).ssh_auth_method == AuthMethod::Password;
let is_editing = view.read(cx).editing_session_id.is_some();
@@ -110,24 +108,36 @@ impl Ashell {
.cursor_pointer()
.on_mouse_down(
MouseButton::Left,
window.listener_for(&view, |this, _, window, cx| {
this.pick_ssh_key_path(window, cx);
}),
window.listener_for(
&view,
|this, _, window, cx| {
this.pick_ssh_key_path(window, cx);
},
),
)
.child(Input::new(&key_path_input).tab_index(5)),
.child(
Input::new(&key_path_input).tab_index(4),
),
)
.child(
Button::new("clear-key-path")
.ghost()
.icon(IconName::Close)
.on_click(window.listener_for(&view, |this, _, window, cx| {
Self::set_input_value(&this.key_path_input, "", window, cx);
}))
.on_click(window.listener_for(
&view,
|this, _, window, cx| {
Self::set_input_value(
&this.key_path_input,
"",
window,
cx,
);
},
)),
),
)
.child(
Input::new(&key_inline_input).h(px(128.)).tab_index(6),
)
.child(Input::new(&key_inline_input).h(px(128.)).tab_index(5))
.child(Input::new(&passphrase_input).mask_toggle().tab_index(6))
})
.child(
h_flex()
@@ -397,346 +407,400 @@ impl Ashell {
pub(crate) fn show_transfers_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let view = cx.entity();
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
dialog
.w(px(600.))
.close_button(false)
.content({
let view = view.clone();
move |content, window, cx| {
let can_clear = view.read(cx).transfers.iter().any(|t| {
!matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
)
});
let clear_btn = if can_clear {
Some(
Button::new("clear_transfers_btn")
.small()
.ghost()
.icon(IconName::Delete)
.label(t!("clear_transfers").to_string())
.on_click(window.listener_for(&view, |this, _, _, cx| {
this.transfers.retain(|t| {
matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
)
});
this.config.set_transfers(this.transfers.clone());
cx.notify();
})),
)
} else {
None
};
let header = h_flex()
.w_full()
.justify_between()
.items_center()
.child(
h_flex()
.items_baseline()
.child(
div()
.text_lg()
.font_weight(FontWeight::SEMIBOLD)
.child(t!("transfers").to_string()),
)
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.ml_2()
.child(t!("transfers_limit").to_string()),
),
)
.child(
h_flex()
.gap_2()
.children(clear_btn)
.child(
Button::new("close_dialog")
.small()
.ghost()
.icon(IconName::Close)
.on_click(|_, window, cx| {
window.close_dialog(cx);
}),
),
);
let mut transfers = view.read(cx).transfers.clone();
transfers.sort_by_key(|t| match t.state {
dialog.w(px(600.)).close_button(false).content({
let view = view.clone();
move |content, window, cx| {
let can_clear = view.read(cx).transfers.iter().any(|t| {
!matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused => 0,
_ => 1,
});
| crate::terminal::TransferState::Paused
)
});
if transfers.is_empty() {
return content.child(
v_flex().gap_2().child(header).child(
div()
.p_4()
.text_center()
.text_color(cx.theme().muted_foreground)
.child(t!("no_transfers_yet").to_string()),
),
);
}
let list = v_flex().gap_2().children(transfers.into_iter().map(|t| {
let (icon, _color) = match t.info.kind {
crate::terminal::TransferType::Upload => {
(IconName::ArrowUp, cx.theme().primary)
}
crate::terminal::TransferType::Download => {
(IconName::ArrowDown, cx.theme().success)
}
};
let (status_text, actions) = match t.state {
crate::terminal::TransferState::Running => {
let percent = t
.total
.map(|tot| {
(t.transferred as f64 / tot as f64 * 100.0)
.clamp(0.0, 100.0)
})
.unwrap_or(0.0);
let txt = if let Some(tot) = t.total {
format!(
"{:.1}% ({}/{})",
percent,
format_bytes(t.transferred),
format_bytes(tot)
let clear_btn = if can_clear {
Some(
Button::new("clear_transfers_btn")
.small()
.ghost()
.icon(IconName::Delete)
.label(t!("clear_transfers").to_string())
.on_click(window.listener_for(&view, |this, _, _, cx| {
this.transfers.retain(|t| {
matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
)
} else {
format!("{}...", t!("downloading"))
};
let btn_pause = Button::new(SharedString::from(format!(
"pause-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Pause)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.pause_transfer(id.clone());
}
}
}));
let btn_cancel = Button::new(SharedString::from(format!(
"cancel-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.cancel_transfer(id.clone());
}
}
}));
(txt, h_flex().gap_1().child(btn_pause).child(btn_cancel))
}
crate::terminal::TransferState::Paused => {
let txt = t!("paused").to_string();
let btn_resume = Button::new(SharedString::from(format!(
"resume-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Play)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.resume_transfer(id.clone());
}
}
}));
let btn_cancel = Button::new(SharedString::from(format!(
"cancel-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.cancel_transfer(id.clone());
}
}
}));
(txt, h_flex().gap_1().child(btn_resume).child(btn_cancel))
}
crate::terminal::TransferState::Completed => {
let txt = t!("completed").to_string();
let mut actions = h_flex().gap_1();
if matches!(
t.info.kind,
crate::terminal::TransferType::Download
) {
let btn_folder = Button::new(SharedString::from(format!(
"folder-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Folder)
.on_click({
let target = t.info.target.clone();
move |_, _, _| {
let _ = std::process::Command::new("open")
.arg(&target)
.spawn();
}
});
actions = actions.child(btn_folder);
}
(txt, actions)
}
crate::terminal::TransferState::Failed(ref err) => {
(format!("{}: {}", t!("failed"), err), h_flex().gap_1())
}
crate::terminal::TransferState::Cancelled => {
(t!("cancelled").to_string(), h_flex().gap_1())
}
};
});
this.config.set_transfers(this.transfers.clone());
cx.notify();
})),
)
} else {
None
};
let percent = match t.state {
crate::terminal::TransferState::Completed => 100.0,
_ => t
.total
.map(|tot| t.transferred as f64 / tot as f64 * 100.0)
.unwrap_or(0.0),
};
v_flex()
.gap_1()
.p_2()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().muted)
let header = h_flex()
.w_full()
.justify_between()
.items_center()
.child(
h_flex()
.items_baseline()
.child(
h_flex()
.items_center()
.gap_2()
.child(
Button::new(SharedString::from(format!(
"icon-{}",
t.info.id
)))
.icon(icon)
.ghost()
.small()
.disabled(true),
)
.child(
v_flex()
.flex_1()
.min_w(px(0.))
.overflow_hidden()
.child(
div()
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().foreground)
.overflow_hidden()
.child(t.info.name.clone()),
)
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().muted_foreground)
.overflow_hidden()
.child(format!(
"{}: {}",
t!("session"),
t.tab_title
)),
),
)
.child(
div()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.child(status_text),
)
.child(actions),
div()
.text_lg()
.font_weight(FontWeight::SEMIBOLD)
.child(t!("transfers").to_string()),
)
.when(
matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
),
|this| {
this.child(
Progress::new(format!("progress-{}", t.info.id))
.with_size(px(4.))
.value(percent as f32)
.color(cx.theme().primary)
.w_full(),
)
},
)
}));
.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.ml_2()
.child(t!("transfers_limit").to_string()),
),
)
.child(
h_flex().gap_2().children(clear_btn).child(
Button::new("close_dialog")
.small()
.ghost()
.icon(IconName::Close)
.on_click(|_, window, cx| {
window.close_dialog(cx);
}),
),
);
let mut transfers = view.read(cx).transfers.clone();
transfers.sort_by_key(|t| match t.state {
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused => 0,
_ => 1,
});
let scroll_handle = window
.use_keyed_state("transfers-scroll", cx, |_, _| {
gpui::ScrollHandle::default()
})
.read(cx)
.clone();
content.child(
if transfers.is_empty() {
return content.child(
v_flex().gap_2().child(header).child(
div()
.w_full()
.relative()
.p_4()
.text_center()
.text_color(cx.theme().muted_foreground)
.child(t!("no_transfers_yet").to_string()),
),
);
}
let list = v_flex().gap_2().children(transfers.into_iter().map(|t| {
let (icon, _color) = match t.info.kind {
crate::terminal::TransferType::Upload => {
(IconName::ArrowUp, cx.theme().primary)
}
crate::terminal::TransferType::Download => {
(IconName::ArrowDown, cx.theme().success)
}
};
let (status_text, actions) = match t.state {
crate::terminal::TransferState::Running => {
let percent = t
.total
.map(|tot| {
(t.transferred as f64 / tot as f64 * 100.0)
.clamp(0.0, 100.0)
})
.unwrap_or(0.0);
let txt = if let Some(tot) = t.total {
format!(
"{:.1}% ({}/{})",
percent,
format_bytes(t.transferred),
format_bytes(tot)
)
} else {
match t.info.kind {
crate::terminal::TransferType::Upload => {
format!("{}...", t!("uploading"))
}
crate::terminal::TransferType::Download => {
format!("{}...", t!("downloading"))
}
}
};
let btn_pause =
Button::new(SharedString::from(format!("pause-{}", t.info.id)))
.ghost()
.small()
.icon(IconName::Pause)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.pause_transfer(id.clone());
}
}
}));
let btn_cancel = Button::new(SharedString::from(format!(
"cancel-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.cancel_transfer(id.clone());
}
}
}));
(txt, h_flex().gap_1().child(btn_pause).child(btn_cancel))
}
crate::terminal::TransferState::Paused => {
let txt = t!("paused").to_string();
let btn_resume = Button::new(SharedString::from(format!(
"resume-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Play)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.resume_transfer(id.clone());
}
}
}));
let btn_cancel = Button::new(SharedString::from(format!(
"cancel-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, _| {
if let Some(handle) = this.active_sftp_handle() {
handle.cancel_transfer(id.clone());
}
}
}));
(txt, h_flex().gap_1().child(btn_resume).child(btn_cancel))
}
crate::terminal::TransferState::Interrupted(ref reason) => {
let txt = format!("{}: {}", t!("interrupted"), reason);
let btn_remove = Button::new(SharedString::from(format!(
"remove-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, cx| {
this.remove_transfer(&id, cx);
}
}));
(txt, h_flex().gap_1().child(btn_remove))
}
crate::terminal::TransferState::Completed => {
let txt = t!("completed").to_string();
let mut actions = h_flex().gap_1();
if matches!(t.info.kind, crate::terminal::TransferType::Download) {
let btn_folder = Button::new(SharedString::from(format!(
"folder-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Folder)
.on_click({
let target = t.info.target.clone();
move |_, _, _| {
let _ = std::process::Command::new("open")
.arg(&target)
.spawn();
}
});
actions = actions.child(btn_folder);
}
let btn_remove = Button::new(SharedString::from(format!(
"remove-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, cx| {
this.remove_transfer(&id, cx);
}
}));
actions = actions.child(btn_remove);
(txt, actions)
}
crate::terminal::TransferState::Failed(ref err) => {
let txt = format!("{}: {}", t!("failed"), err);
let btn_remove = Button::new(SharedString::from(format!(
"remove-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, cx| {
this.remove_transfer(&id, cx);
}
}));
(txt, h_flex().gap_1().child(btn_remove))
}
crate::terminal::TransferState::Zombie(ref reason) => {
let txt = format!("{}: {}", t!("zombie"), reason);
let btn_remove = Button::new(SharedString::from(format!(
"remove-{}",
t.info.id
)))
.ghost()
.small()
.icon(IconName::Close)
.on_click(window.listener_for(&view, {
let id = t.info.id.clone();
move |this, _, _, cx| {
this.remove_transfer(&id, cx);
}
}));
(txt, h_flex().gap_1().child(btn_remove))
}
};
let percent = match t.state {
crate::terminal::TransferState::Completed => 100.0,
_ => t
.total
.map(|tot| t.transferred as f64 / tot as f64 * 100.0)
.unwrap_or(0.0),
};
v_flex()
.gap_1()
.p_2()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().muted)
.child(
h_flex()
.items_center()
.gap_2()
.child(
div()
.w_full()
.max_h(px(400.))
.flex_col()
.id("transfers-scroll-view")
.track_scroll(&scroll_handle)
.overflow_y_scroll()
.pr(px(14.))
.child(list),
Button::new(SharedString::from(format!(
"icon-{}",
t.info.id
)))
.icon(icon)
.ghost()
.small()
.disabled(true),
)
.child(
div()
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(16.))
v_flex()
.flex_1()
.min_w(px(0.))
.overflow_hidden()
.child(
Scrollbar::vertical(&scroll_handle)
.scrollbar_show(ScrollbarShow::Always),
div()
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().foreground)
.overflow_hidden()
.child(t.info.name.clone()),
)
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().muted_foreground)
.overflow_hidden()
.child(format!(
"{}: {}",
t!("session"),
t.tab_title
)),
)
.child(
div()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.child(status_text.clone()),
),
),
)
.child(actions),
)
)
}
})
.when(
matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
),
|this| {
this.child(
Progress::new(format!("progress-{}", t.info.id))
.with_size(px(4.))
.value(percent as f32)
.color(cx.theme().primary)
.w_full(),
)
},
)
}));
let scroll_handle = window
.use_keyed_state("transfers-scroll", cx, |_, _| {
gpui::ScrollHandle::default()
})
.read(cx)
.clone();
content.child(
v_flex().gap_2().child(header).child(
div()
.w_full()
.relative()
.child(
div()
.w_full()
.max_h(px(400.))
.flex_col()
.id("transfers-scroll-view")
.track_scroll(&scroll_handle)
.overflow_y_scroll()
.pr(px(14.))
.child(list),
)
.child(
div()
.absolute()
.top_0()
.right_0()
.bottom_0()
.w(px(16.))
.child(
Scrollbar::vertical(&scroll_handle)
.scrollbar_show(ScrollbarShow::Always),
),
),
),
)
}
})
});
}
pub(crate) fn show_delete_confirm_dialog(
@@ -792,9 +856,7 @@ impl Ashell {
view.update(cx, |this, cx| {
if let Some(handle) = this.active_sftp_handle() {
let _ = handle.commands.send(
crate::sftp::SftpCommand::DeletePaths(
paths_to_delete.clone(),
),
crate::sftp::SftpCommand::DeletePaths(paths_to_delete.clone()),
);
}
if let Some(sftp) = this.active_sftp_mut() {
@@ -936,11 +998,32 @@ impl Ashell {
}
pub(crate) fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let view = cx.entity();
// Unbind all workspace keys so they don't interfere with keybinding recording
crate::app::keybinding_recorder::unbind_all_workspace_keys(cx, &self.config);
self.keybinds_suspended = true;
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
dialog
.title(t!("settings").to_string())
.w(px(840.))
.h(px(560.))
.on_close({
let view = view.clone();
move |_, _window, cx| {
// Re-register all workspace keys when closing settings
view.update(cx, |this, cx| {
this.keybinds_suspended = false;
this.recording_action = None;
this.keybind_error = None;
crate::app::keybinding_recorder::bind_workspace_keys_from_config(
cx,
&this.config,
);
cx.notify();
});
}
})
.content({
let view = view.clone();
move |content, _window, cx| {
@@ -949,10 +1032,78 @@ impl Ashell {
let version = env!("CARGO_PKG_VERSION");
let view_clone_for_general = view.clone();
let focus_handle = view.read(cx).focus_handle.clone();
content.child(
Settings::new("settings")
.sidebar_width(px(180.))
.sidebar_style(div().bg(cx.theme().background).style())
div()
.flex()
.flex_col()
.size_full()
.track_focus(&focus_handle)
.on_key_down({
let view = view.clone();
move |ev: &gpui::KeyDownEvent, window, cx| {
view.update(cx, |this, cx| {
let Some(action) = this.recording_action.clone() else {
return;
};
window.prevent_default();
cx.stop_propagation();
if ev.keystroke.key == "escape" {
this.recording_action = None;
cx.notify();
return;
}
let Some(new_key) = crate::app::keybinding_recorder::normalize_recorded_keystroke(ev) else {
return;
};
// Check for conflicts with other actions
if let Some((_conflict_id, conflict_label)) =
crate::app::keybinding_recorder::find_conflict(
&this.config,
&action,
&new_key,
)
{
let formatted = crate::app::keybinding_recorder::format_keystroke(&new_key);
this.recording_action = None;
this.keybind_error = Some((
action.clone(),
t!("keybind_conflict", key = formatted, action = conflict_label).to_string(),
));
cx.notify();
return;
}
this.recording_action = None;
this.keybind_error = None;
this.config.set_key_binding(&action, &new_key);
if let Err(err) = this.config.save() {
tracing::error!("failed to save key binding: {err:#}");
}
cx.notify();
});
}
})
.on_mouse_down_out({
let view = view.clone();
move |_, _window, cx| {
view.update(cx, |this, cx| {
if this.recording_action.is_some() {
this.recording_action = None;
cx.notify();
}
});
}
})
.child(
Settings::new("settings")
.sidebar_width(px(180.))
.sidebar_style(div().bg(cx.theme().background).style())
.page(
SettingPage::new(t!("settings_general").to_string())
.icon(IconName::Settings)
@@ -1393,6 +1544,7 @@ impl Ashell {
.page(
SettingPage::new(t!("settings_key_bindings").to_string())
.icon(IconName::SquareTerminal)
.group(crate::app::keybinding_recorder::KeybindingsPage::render(&view, cx))
)
.page(
SettingPage::new(t!("settings_help").to_string())
@@ -1432,6 +1584,7 @@ impl Ashell {
}))
)
)
)
)
}
})
+334
View File
@@ -0,0 +1,334 @@
use gpui::{
Action as _, App, Entity, IntoElement, KeyBinding, KeyDownEvent, Keystroke, Unbind, prelude::*,
};
use gpui_component::{
Sizable,
button::{Button, ButtonVariants},
kbd::Kbd,
setting::{SettingField, SettingGroup, SettingItem},
};
use rust_i18n::t;
use crate::{Ashell, session::config::ConfigStore};
gpui::actions!(
ashell_workspace,
[
OpenSettings,
OpenSession,
NewSsh,
ToggleSftpZoom,
FocusPaneLeft,
FocusPaneRight,
FocusPaneUp,
FocusPaneDown,
SplitPaneLeft,
SplitPaneRight,
SplitPaneUp,
SplitPaneDown,
ClosePane
]
);
pub struct KeybindingsPage;
#[derive(Clone, Copy)]
pub(crate) struct WorkspaceAction {
id: &'static str,
label_key: &'static str,
default_suffix: &'static str,
}
pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
WorkspaceAction {
id: "OpenSettings",
label_key: "settings_open_settings",
default_suffix: ",",
},
WorkspaceAction {
id: "OpenSession",
label_key: "settings_open_session",
default_suffix: "o",
},
WorkspaceAction {
id: "NewSsh",
label_key: "settings_new_ssh",
default_suffix: "n",
},
WorkspaceAction {
id: "ToggleSftpZoom",
label_key: "settings_toggle_sftp_zoom",
default_suffix: "m",
},
WorkspaceAction {
id: "FocusPaneLeft",
label_key: "settings_focus_pane_left",
default_suffix: "h",
},
WorkspaceAction {
id: "FocusPaneRight",
label_key: "settings_focus_pane_right",
default_suffix: "l",
},
WorkspaceAction {
id: "FocusPaneUp",
label_key: "settings_focus_pane_up",
default_suffix: "k",
},
WorkspaceAction {
id: "FocusPaneDown",
label_key: "settings_focus_pane_down",
default_suffix: "j",
},
WorkspaceAction {
id: "SplitPaneLeft",
label_key: "settings_split_pane_left",
default_suffix: "shift-h",
},
WorkspaceAction {
id: "SplitPaneRight",
label_key: "settings_split_pane_right",
default_suffix: "shift-l",
},
WorkspaceAction {
id: "SplitPaneUp",
label_key: "settings_split_pane_up",
default_suffix: "shift-k",
},
WorkspaceAction {
id: "SplitPaneDown",
label_key: "settings_split_pane_down",
default_suffix: "shift-j",
},
WorkspaceAction {
id: "ClosePane",
label_key: "settings_close_pane",
default_suffix: "w",
},
];
pub(crate) fn default_modifier() -> &'static str {
if cfg!(target_os = "macos") {
"cmd"
} else {
"ctrl"
}
}
pub(crate) fn default_keystroke(action_id: &str) -> Option<String> {
WORKSPACE_ACTIONS
.iter()
.find(|action| action.id == action_id)
.map(|action| format!("{}-{}", default_modifier(), action.default_suffix))
}
pub(crate) fn configured_keystroke(config: &ConfigStore, action_id: &str) -> Option<String> {
config
.key_bindings()
.get(action_id)
.cloned()
.or_else(|| default_keystroke(action_id))
}
pub(crate) fn normalize_recorded_keystroke(event: &KeyDownEvent) -> Option<String> {
let key = event.keystroke.key.trim();
if key.is_empty() {
return None;
}
let mut parts = Vec::new();
if event.keystroke.modifiers.control {
parts.push("ctrl".to_string());
}
if event.keystroke.modifiers.alt {
parts.push("alt".to_string());
}
if event.keystroke.modifiers.shift {
parts.push("shift".to_string());
}
if event.keystroke.modifiers.platform {
parts.push("cmd".to_string());
}
if event.keystroke.modifiers.function {
parts.push("fn".to_string());
}
parts.push(key.to_ascii_lowercase());
let keystroke = parts.join("-");
Keystroke::parse(&keystroke).ok().map(|_| keystroke)
}
pub(crate) fn format_keystroke(keystroke: &str) -> String {
Keystroke::parse(keystroke)
.map(|stroke| Kbd::format(&stroke))
.unwrap_or_else(|_| keystroke.to_string())
}
pub(crate) fn bind_workspace_keys_from_config(cx: &mut App, config: &ConfigStore) {
bind_workspace_actions(cx, config);
}
/// Unbind all workspace keybindings (used when entering keybinding settings to prevent interference).
pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
macro_rules! unbind_action {
($id:literal, $action:expr) => {
let default = default_keystroke($id).expect("workspace action has default key");
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
let action_name = $action.name();
// Unbind both the default and configured keystroke
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
if configured != default {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
None,
));
}
};
}
unbind_action!("OpenSettings", crate::OpenSettings);
unbind_action!("OpenSession", crate::OpenSession);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
unbind_action!("FocusPaneRight", crate::FocusPaneRight);
unbind_action!("FocusPaneUp", crate::FocusPaneUp);
unbind_action!("FocusPaneDown", crate::FocusPaneDown);
unbind_action!("SplitPaneLeft", crate::SplitPaneLeft);
unbind_action!("SplitPaneRight", crate::SplitPaneRight);
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
unbind_action!("ClosePane", crate::ClosePane);
cx.bind_keys(bindings);
}
/// Check if a keystroke conflicts with any other action's binding.
/// Returns Some((conflicting_action_id, label)) if there is a conflict.
pub(crate) fn find_conflict(
config: &ConfigStore,
current_action_id: &str,
new_keystroke: &str,
) -> Option<(String, String)> {
for action in WORKSPACE_ACTIONS {
if action.id == current_action_id {
continue;
}
let existing = configured_keystroke(config, action.id).unwrap_or_default();
if !existing.is_empty() && existing == new_keystroke {
return Some((action.id.to_string(), t!(action.label_key).to_string()));
}
}
None
}
fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
macro_rules! bind_action {
($id:literal, $action:expr) => {
let default = default_keystroke($id).expect("workspace action has default key");
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
let action_name = $action.name();
if configured != default {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
bindings.push(KeyBinding::new(&configured, $action, None));
};
}
bind_action!("OpenSettings", crate::OpenSettings);
bind_action!("OpenSession", crate::OpenSession);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
bind_action!("FocusPaneRight", crate::FocusPaneRight);
bind_action!("FocusPaneUp", crate::FocusPaneUp);
bind_action!("FocusPaneDown", crate::FocusPaneDown);
bind_action!("SplitPaneLeft", crate::SplitPaneLeft);
bind_action!("SplitPaneRight", crate::SplitPaneRight);
bind_action!("SplitPaneUp", crate::SplitPaneUp);
bind_action!("SplitPaneDown", crate::SplitPaneDown);
bind_action!("ClosePane", crate::ClosePane);
cx.bind_keys(bindings);
}
impl KeybindingsPage {
pub fn render(view: &Entity<Ashell>, cx: &mut App) -> SettingGroup {
let mut group = SettingGroup::new();
for action in WORKSPACE_ACTIONS {
let recording = view.read(cx).recording_action.as_deref() == Some(action.id);
let has_error = view
.read(cx)
.keybind_error
.as_ref()
.is_some_and(|(id, _)| id == action.id);
let error_msg = if has_error {
view.read(cx)
.keybind_error
.as_ref()
.map(|(_, msg)| msg.clone())
} else {
None
};
let keystroke = {
let config = &view.read(cx).config;
configured_keystroke(config, action.id).unwrap_or_default()
};
let btn_label = if recording {
t!("press_new_key").to_string()
} else if keystroke.is_empty() {
t!("none").to_string()
} else {
format_keystroke(&keystroke)
};
let mut item = SettingItem::new(
t!(action.label_key).to_string(),
SettingField::render({
let view = view.clone();
let action_id = action.id.to_string();
move |_, _window, _cx| {
Button::new(gpui::SharedString::from(format!("keybind-{action_id}")))
.label(btn_label.clone())
.small()
.when(recording, |this| this.primary())
.when(has_error, |this| this.danger())
.on_click({
let view = view.clone();
let action_id = action_id.clone();
move |_event, window, cx| {
view.update(cx, |this, cx| {
// Clear any previous error when starting new recording
this.keybind_error = None;
this.recording_action = Some(action_id.clone());
this.focus_handle.focus(window, cx);
cx.notify();
});
}
})
.into_any_element()
}
}),
);
if let Some(msg) = error_msg {
item = item.description(msg);
}
group = group.item(item);
}
group
}
}
+101 -41
View File
@@ -1,5 +1,6 @@
pub mod constants;
pub mod dialogs;
pub mod keybinding_recorder;
pub mod startup;
pub mod theme;
pub mod ui;
@@ -14,9 +15,8 @@ use std::{
};
use gpui::{
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point,
SharedString, Size, UniformListScrollHandle, Window, point,
px, size,
AppContext as _, Bounds, Context, Entity, FocusHandle, Pixels, Point, SharedString, Size,
UniformListScrollHandle, Window, point, px, size,
};
use gpui_component::{
Theme, ThemeMode, ThemeRegistry,
@@ -31,7 +31,6 @@ use crate::{
session::config::{AuthMethod, ConfigStore},
system::{SystemSampler, SystemSnapshot},
terminal::{self, BackendEvent, TabKind, TerminalTab},
};
#[derive(Clone, Debug)]
@@ -82,7 +81,10 @@ impl PaneLayout {
pub fn replace_at(&mut self, path: &[usize], replacement: PaneLayout) {
match (self, path) {
(this @ PaneLayout::Single(_), []) => *this = replacement,
(PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), [first, rest @ ..]) => {
(
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
[first, rest @ ..],
) => {
if let Some(child) = children.get_mut(*first) {
child.replace_at(rest, replacement);
}
@@ -187,8 +189,6 @@ impl ScrollbarHandle for TerminalScrollbarHandle {
}
}
pub(crate) struct Ashell {
pub(crate) focus_handle: FocusHandle,
pub(crate) selector_focus_handle: FocusHandle,
@@ -199,6 +199,7 @@ pub(crate) struct Ashell {
pub(crate) password_input: Entity<InputState>,
pub(crate) key_path_input: Entity<InputState>,
pub(crate) key_inline_input: Entity<InputState>,
pub(crate) passphrase_input: Entity<InputState>,
pub(crate) sftp_path_input: Entity<InputState>,
pub(crate) ssh_auth_method: AuthMethod,
pub(crate) editing_session_id: Option<String>,
@@ -237,6 +238,7 @@ pub(crate) struct Ashell {
pub(crate) system_status: Option<SharedString>,
pub(crate) pane_root: PaneLayout,
pub(crate) focused_pane_path: Vec<usize>,
pub(crate) terminal_panel_bounds: Option<Bounds<Pixels>>,
pub(crate) terminal_bounds: HashMap<String, Bounds<Pixels>>,
pub(crate) terminal_selecting: bool,
pub(crate) dragging_splitter: Option<(Vec<usize>, usize)>, // (parent_path, child_index)
@@ -247,6 +249,11 @@ pub(crate) struct Ashell {
pub(crate) status: SharedString,
pub(crate) config: ConfigStore,
pub(crate) system_sampler: SystemSampler,
pub(crate) recording_action: Option<String>,
/// Error message when a recorded keybinding conflicts with another
pub(crate) keybind_error: Option<(String, String)>, // (action_id, error_message)
/// Whether workspace keybindings are currently suspended (during settings)
pub(crate) keybinds_suspended: bool,
pub(crate) system: SystemSnapshot,
pub(crate) cpu_history: Vec<f32>,
pub(crate) net_rx_history: Vec<f32>,
@@ -256,7 +263,7 @@ pub(crate) struct Ashell {
pub(crate) system_tab_id: Option<String>,
pub(crate) sftp_handles: std::collections::HashMap<String, crate::sftp::SftpHandle>,
pub(crate) remote_sample_in_flight: bool,
pub(crate) runtime: Runtime,
pub(crate) events_rx: mpsc::Receiver<BackendEvent>,
@@ -287,6 +294,26 @@ pub(crate) struct SftpContextMenuState {
}
impl Ashell {
fn transfer_source_title(&self, tab_id: &str) -> String {
self.tabs
.iter()
.find(|tab| tab.id == tab_id)
.map(|tab| tab.title.clone())
.or_else(|| {
self.tab_groups
.iter()
.find(|group| group.id == tab_id)
.map(|group| group.title.clone())
})
.or_else(|| {
self.tab_groups
.iter()
.find(|group| group.pane_root.contains(tab_id))
.map(|group| group.title.clone())
})
.unwrap_or_else(|| "Unknown".to_string())
}
pub(crate) fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let host_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("host")));
let session_name_input =
@@ -306,8 +333,14 @@ impl Ashell {
.rows(5)
.placeholder("-----BEGIN OPENSSH PRIVATE KEY-----")
});
let passphrase_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("SSH private key passphrase (optional)")
.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 sftp_new_folder_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string()));
let _subscriptions = vec![
cx.subscribe_in(&host_input, window, Self::on_input_event),
@@ -317,6 +350,7 @@ impl Ashell {
cx.subscribe_in(&password_input, window, Self::on_input_event),
cx.subscribe_in(&key_path_input, window, Self::on_input_event),
cx.subscribe_in(&key_inline_input, window, Self::on_input_event),
cx.subscribe_in(&passphrase_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_path_input, window, Self::on_input_event),
cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event),
];
@@ -332,12 +366,7 @@ impl Ashell {
tracing::warn!("failed to load config: {err:#}");
ConfigStore::in_memory()
});
let follow_system_theme =
if config.light_theme_name().is_empty() && config.dark_theme_name().is_empty() {
true
} else {
config.follow_system_theme()
};
let follow_system_theme = config.follow_system_theme();
let theme_mode = match config.theme_mode() {
"light" => ThemeMode::Light,
@@ -379,6 +408,7 @@ impl Ashell {
password_input,
key_path_input,
key_inline_input,
passphrase_input,
sftp_path_input,
ssh_auth_method: AuthMethod::Password,
editing_session_id: None,
@@ -396,6 +426,7 @@ impl Ashell {
active_group: None,
pane_root: PaneLayout::Single(String::new()),
focused_pane_path: Vec::new(),
terminal_panel_bounds: None,
selector_selection: 0,
workspace_panels,
body_panels,
@@ -414,7 +445,20 @@ impl Ashell {
sftp_new_folder_input,
sftp_delete_scroll_handle: gpui::ScrollHandle::new(),
show_hidden_files: config.show_hidden_files(),
transfers: config.transfers(),
transfers: {
let mut transfers = config.transfers();
for t in transfers.iter_mut() {
if matches!(
t.state,
crate::terminal::TransferState::Running
| crate::terminal::TransferState::Paused
) {
t.state =
crate::terminal::TransferState::Zombie(t!("zombie_reason").to_string());
}
}
transfers
},
show_transfers_dialog: false,
system_status: None,
terminal_bounds: HashMap::new(),
@@ -427,6 +471,9 @@ impl Ashell {
status: "ready".into(),
config,
system_sampler,
recording_action: None,
keybind_error: None,
keybinds_suspended: false,
system,
cpu_history: Vec::with_capacity(20),
net_rx_history: Vec::with_capacity(20),
@@ -436,7 +483,7 @@ impl Ashell {
system_tab_id: None,
sftp_handles: std::collections::HashMap::new(),
remote_sample_in_flight: false,
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
@@ -478,7 +525,9 @@ impl Ashell {
let base_path = self.sftp_path_input.read(cx).text().to_string();
let path = crate::sftp::join_remote(&base_path, &name);
if let Some(handle) = self.active_sftp_handle() {
let _ = handle.commands.send(crate::sftp::SftpCommand::CreateDir(path));
let _ = handle
.commands
.send(crate::sftp::SftpCommand::CreateDir(path));
}
}
self.sftp_creating_folder = false;
@@ -545,7 +594,8 @@ impl Ashell {
if progress.tab_id == tab_id {
progress.lines.push(text.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
}
}
self.status = text.into();
@@ -636,8 +686,8 @@ impl Ashell {
if self.system_tab_id.as_deref() == Some(tab_id.as_str()) {
self.system_status = Some(reason.clone().into());
}
let is_graceful_exit = reason == "local shell closed"
|| reason == "ssh session closed";
let is_graceful_exit =
reason == "local shell closed" || reason == "ssh session closed";
// Auto-close the pane on graceful exit (e.g. user typed exit)
if is_graceful_exit {
self.handle_tab_close(tab_id.clone());
@@ -650,7 +700,8 @@ impl Ashell {
if progress.tab_id == tab_id {
progress.lines.push(reason.clone().into());
let _idx = progress.lines.len().saturating_sub(1);
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
let _ = session_label;
let _ = tab_title;
progress.title = t!("connection_failed").into();
@@ -661,14 +712,16 @@ impl Ashell {
progress.tab_id = tab_id.clone();
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
progress.title = t!("connection_failed").into();
progress.failed = true;
} else {
// Already showing a failure dialog, just append the new failure
let msg = format!("{}: {}", tab_title.unwrap_or_default(), reason);
progress.lines.push(msg.into());
self.connection_scroll_handle.set_offset(point(px(0.), px(-99999.0)));
self.connection_scroll_handle
.set_offset(point(px(0.), px(-99999.0)));
}
} else if let Some(_) = session_label {
needs_new_progress = true;
@@ -701,12 +754,7 @@ impl Ashell {
}
}
BackendEvent::TransferStarted { tab_id, info } => {
let tab_title = self
.tabs
.iter()
.find(|t| t.id == tab_id)
.map(|t| t.title.clone())
.unwrap_or_else(|| "Unknown".to_string());
let tab_title = self.transfer_source_title(&tab_id);
self.transfers.insert(
0,
crate::terminal::Transfer {
@@ -748,7 +796,12 @@ impl Ashell {
self.last_system_sample = Instant::now();
// Use system_tab_id (not active_tab) to decide remote vs local sampling
if let Some(ref tab_id) = self.system_tab_id.clone() {
if self.tabs.iter().any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected) && self.system_status.is_none() {
if self
.tabs
.iter()
.any(|t| t.id == *tab_id && t.kind == TabKind::Ssh && t.connected)
&& self.system_status.is_none()
{
self.request_active_system_snapshot();
return false;
}
@@ -782,12 +835,18 @@ 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(ref tab_id) = self.system_tab_id.clone() else {
return;
};
let Some(backend) = (|| {
let tab = self.tabs.iter().find(|t| t.id == *tab_id)?;
if !tab.connected { return None; }
if !tab.connected {
return None;
}
Some(tab.backend.clone())
})() else { return };
})() else {
return;
};
if self.remote_sample_in_flight {
return;
}
@@ -807,15 +866,16 @@ impl Ashell {
let x = element_bounds.origin.x
+ px(cell_width) * cursor.col as f32
+ px(cell_width) * range_utf16.start as f32;
let y = element_bounds.origin.y
+ px(line_height) * cursor.row as f32;
let y = element_bounds.origin.y + px(line_height) * cursor.row as f32;
Some(Bounds::new(
point(x, y),
size(
px(cell_width),
px(line_height),
),
size(px(cell_width), px(line_height)),
))
}
}
pub(crate) fn remove_transfer(&mut self, transfer_id: &str, cx: &mut Context<Self>) {
self.transfers.retain(|t| t.info.id != transfer_id);
self.config.set_transfers(self.transfers.clone());
cx.notify();
}
}
+57 -31
View File
@@ -1,8 +1,13 @@
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
use gpui_component::Root;
use crate::session::config::ConfigStore;
use crate::Ashell;
use crate::session::config::ConfigStore;
pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
crate::app::keybinding_recorder::bind_workspace_keys_from_config(cx, &config);
}
struct LocalMinutelyRoller {
dir: std::path::PathBuf,
@@ -13,9 +18,14 @@ struct LocalMinutelyRoller {
impl LocalMinutelyRoller {
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
Self { dir, prefix, current_minute: 60, file: None }
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();
@@ -28,14 +38,18 @@ impl LocalMinutelyRoller {
.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));
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());
@@ -57,7 +71,7 @@ impl std::io::Write for LocalMinutelyRoller {
Ok(buf.len())
}
}
fn flush(&mut self) -> std::io::Result<()> {
if let Some(f) = &mut self.file {
f.flush()
@@ -73,11 +87,11 @@ pub(crate) fn init_logging() {
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);
@@ -86,11 +100,15 @@ pub(crate) fn init_logging() {
.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))
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)
@@ -107,7 +125,10 @@ pub(crate) fn init_logging() {
#[cfg(target_os = "macos")]
pub(crate) fn sync_macos_launch_environment() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let Ok(output) = std::process::Command::new(&shell).args(["-l", "-c", "env -0"]).output() else {
let Ok(output) = std::process::Command::new(&shell)
.args(["-l", "-c", "env -0"])
.output()
else {
return;
};
if !output.status.success() {
@@ -218,7 +239,7 @@ pub(crate) fn open_main_window(cx: &mut App) {
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();
@@ -231,24 +252,30 @@ pub(crate) fn open_main_window(cx: &mut App) {
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let current_bounds = window.window_bounds();
let saved_bounds = match current_bounds {
gpui::WindowBounds::Fullscreen(b) => crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
},
gpui::WindowBounds::Maximized(b) => crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
},
gpui::WindowBounds::Windowed(b) => crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
},
gpui::WindowBounds::Fullscreen(b) => {
crate::session::config::SavedWindowBounds::Fullscreen {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Maximized(b) => {
crate::session::config::SavedWindowBounds::Maximized {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
gpui::WindowBounds::Windowed(b) => {
crate::session::config::SavedWindowBounds::Windowed {
x: b.origin.x.into(),
y: b.origin.y.into(),
width: b.size.width.into(),
height: b.size.height.into(),
}
}
};
let workspace_sizes: Vec<f32> = workspace_panels_clone
.read(cx)
@@ -271,4 +298,3 @@ pub(crate) fn open_main_window(cx: &mut App) {
})
.expect("failed to open window");
}
+25 -11
View File
@@ -1,8 +1,6 @@
use anyhow::{Context as _, Result};
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{
ActiveTheme as _, Theme, ThemeMode, ThemeRegistry,
};
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use crate::Ashell;
@@ -14,9 +12,12 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
];
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let regular =
std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice());
let bold = std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice());
let regular = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
);
let bold = std::borrow::Cow::Borrowed(
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
);
cx.text_system()
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
@@ -39,7 +40,12 @@ pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
}
impl Ashell {
pub(crate) fn switch_theme_mode(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn switch_theme_mode(
&mut self,
mode: ThemeMode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.follow_system_theme = false;
self.theme_mode = mode;
self.apply_theme_preferences(window, cx);
@@ -48,7 +54,12 @@ impl Ashell {
cx.notify();
}
pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn apply_theme(
&mut self,
name: SharedString,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
self.status = format!("theme not found: {name}").into();
cx.notify();
@@ -84,7 +95,12 @@ impl Ashell {
cx.notify();
}
pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn set_display_language(
&mut self,
locale: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.config.set_locale(locale);
let mut active_locale = locale.to_string();
if active_locale == "system" {
@@ -143,6 +159,4 @@ impl Ashell {
tracing::warn!("failed to save theme preferences: {err:#}");
}
}
}
+567 -373
View File
File diff suppressed because it is too large Load Diff
+37 -10
View File
@@ -94,7 +94,9 @@ async fn run_ssh(
),
});
let handle = Arc::new(tokio::sync::Mutex::new(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()
@@ -223,7 +225,11 @@ 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);
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}"),
@@ -231,12 +237,16 @@ 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);
tracing::info!(
"[ssh] sending password authentication for {}@{}",
session.user,
addr
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
@@ -251,7 +261,12 @@ 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);
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}"),
@@ -283,7 +298,11 @@ async fn connect_and_authenticate(
if !success {
return Err(anyhow::anyhow!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user, session.host, session.port, source, algorithm
session.user,
session.host,
session.port,
source,
algorithm
));
}
success
@@ -313,7 +332,11 @@ async fn connect_and_authenticate(
));
}
tracing::info!("[ssh] authentication successful for {}@{}", session.user, addr);
tracing::info!(
"[ssh] authentication successful for {}@{}",
session.user,
addr
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
@@ -329,6 +352,8 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
@@ -339,14 +364,14 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let mut errors = Vec::new();
if has_inline {
match decode_secret_key(&inline_key, None) {
match decode_secret_key(&inline_key, passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("decode private key content: {err}")),
}
}
if let Some(path) = key_path {
match load_secret_key(path.as_path(), None) {
match load_secret_key(path.as_path(), passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
}
@@ -376,7 +401,9 @@ fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashA
}
if algs.is_empty() {
return Err(anyhow!("Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"));
return Err(anyhow!(
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
));
}
Ok(algs)
+17 -3
View File
@@ -1,6 +1,6 @@
#![windows_subsystem = "windows"]
use gpui::{ KeyBinding };
use gpui::KeyBinding;
use gpui_component_assets::Assets;
mod app;
@@ -14,6 +14,11 @@ rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
OpenSettings, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp, ToggleSftpZoom,
};
pub(crate) use app::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
@@ -37,9 +42,18 @@ fn main() {
app.run(move |cx| {
gpui_component::init(cx);
cx.bind_keys([
KeyBinding::new("tab", TerminalTabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
KeyBinding::new("shift-tab", TerminalBacktabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
KeyBinding::new(
"tab",
TerminalTabKey,
Some(app::constants::TERMINAL_KEY_CONTEXT),
),
KeyBinding::new(
"shift-tab",
TerminalBacktabKey,
Some(app::constants::TERMINAL_KEY_CONTEXT),
),
]);
app::startup::bind_workspace_keys(cx);
app::theme::load_embedded_themes(cx);
if let Err(err) = app::theme::load_fonts(cx) {
tracing::warn!("failed to load embedded fonts: {err:#}");
+42 -2
View File
@@ -27,6 +27,8 @@ pub struct Session {
#[serde(default)]
pub private_key_inline: String,
#[serde(default)]
pub passphrase: String,
#[serde(default)]
pub last_used: Option<String>,
}
@@ -43,6 +45,7 @@ impl Session {
password,
private_key_path: String::new(),
private_key_inline: String::new(),
passphrase: String::new(),
last_used: None,
}
}
@@ -53,6 +56,7 @@ impl Session {
user: String,
private_key_path: String,
private_key_inline: String,
passphrase: String,
) -> Self {
let name = format!("{user}@{host}");
Self {
@@ -65,6 +69,7 @@ impl Session {
password: String::new(),
private_key_path,
private_key_inline,
passphrase,
last_used: None,
}
}
@@ -95,7 +100,7 @@ pub enum SavedWindowBounds {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile {
#[serde(default)]
#[serde(default = "default_follow_system_theme")]
pub follow_system_theme: bool,
#[serde(default)]
pub theme_mode: String,
@@ -129,12 +134,18 @@ pub struct ConfigFile {
pub show_hidden_files: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
#[serde(default)]
pub key_bindings: std::collections::HashMap<String, String>,
}
fn default_monitoring_position() -> String {
"Sidebar".to_string()
}
fn default_follow_system_theme() -> bool {
true
}
fn default_locale() -> String {
"system".to_string()
}
@@ -186,7 +197,26 @@ impl ConfigStore {
let cache = if path.exists() {
let raw = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
serde_json::from_str::<ConfigFile>(&raw).unwrap_or_default()
match serde_json::from_str::<ConfigFile>(&raw) {
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(),
);
}
ConfigFile::default()
}
}
} else {
ConfigFile::default()
};
@@ -246,6 +276,16 @@ impl ConfigStore {
self.cache.locale = locale.to_string();
}
pub fn key_bindings(&self) -> &std::collections::HashMap<String, String> {
&self.cache.key_bindings
}
pub fn set_key_binding(&mut self, action_name: &str, keystroke: &str) {
self.cache
.key_bindings
.insert(action_name.to_string(), keystroke.to_string());
}
pub fn monitoring_position(&self) -> &str {
if self.cache.monitoring_position.is_empty() {
"Sidebar"
+176 -72
View File
@@ -1,13 +1,10 @@
pub mod config;
use gpui::{
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton,
MouseDownEvent, MouseMoveEvent, SharedString, Window, px,
};
use gpui_component::{
Theme, WindowExt as _,
input::InputState,
App, AppContext as _, Context, Entity, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, SharedString, Window, px,
};
use gpui_component::{Theme, WindowExt as _, input::InputState};
use rust_i18n::t;
use uuid::Uuid;
@@ -15,9 +12,12 @@ use self::config::{AuthMethod, Session};
use crate::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, TabGroup,
app::constants::{
DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X,
TERMINAL_PADDING_Y,
},
backend::{local, ssh},
terminal::{BackendCommand, RenderSnapshot, TabKind, TerminalTab},
app::constants::{DEFAULT_COLS, DEFAULT_ROWS, SIDEBAR_WIDTH, TAB_BAR_HEIGHT, TERMINAL_PADDING_X, TERMINAL_PADDING_Y},
};
impl Ashell {
@@ -71,6 +71,7 @@ impl Ashell {
let password = self.password_input.read(cx).value().to_string();
let key_path = self.key_path_input.read(cx).value().trim().to_string();
let key_inline = self.key_inline_input.read(cx).value().to_string();
let passphrase = self.passphrase_input.read(cx).value().to_string();
if host.is_empty() || user.is_empty() {
self.status = t!("host_and_user_required").into();
@@ -97,7 +98,7 @@ impl Ashell {
cx.notify();
return;
}
Session::key(host, port, user, key_path, key_inline)
Session::key(host, port, user, key_path, key_inline, passphrase)
}
};
session.name = name;
@@ -134,6 +135,7 @@ impl Ashell {
Self::set_input_value(&self.password_input, "", window, cx);
Self::set_input_value(&self.key_path_input, "", window, cx);
Self::set_input_value(&self.key_inline_input, "", window, cx);
Self::set_input_value(&self.passphrase_input, "", window, cx);
}
pub(crate) fn load_session_into_form(
@@ -161,6 +163,12 @@ impl Ashell {
window,
cx,
);
Self::set_input_value(
&self.passphrase_input,
session.passphrase.clone(),
window,
cx,
);
}
pub(crate) fn pick_ssh_key_path(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -223,7 +231,12 @@ impl Ashell {
};
self.load_session_into_form(&session, window, cx);
self.editing_session_id = None;
Self::set_input_value(&self.session_name_input, format!("{}-copy", session.name), window, cx);
Self::set_input_value(
&self.session_name_input,
format!("{}-copy", session.name),
window,
cx,
);
self.show_ssh_dialog(window, cx);
}
@@ -298,7 +311,10 @@ 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);
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();
@@ -342,7 +358,11 @@ impl Ashell {
}
}
pub(crate) fn activate_selector_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn activate_selector_selection(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let entries = self.selector_entries();
let Some(entry) = entries.get(self.selector_selection).cloned() else {
return;
@@ -392,7 +412,12 @@ 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);
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(),
@@ -429,7 +454,12 @@ impl Ashell {
self.active_group = Some(group_id.clone());
self.tabs_scroll_handle.scroll_to_item(self.tabs.len() - 1);
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
if let Some(index) = self
.config
.sessions()
.iter()
.position(|s| s.id == session_id)
{
self.saved_scroll_handle.scroll_to_item(index);
}
}
@@ -496,15 +526,15 @@ impl Ashell {
);
// Replace tab state in-place to reuse the UI component
self.tabs[ix] = TerminalTab::new_ssh(
tab_id.clone(),
&session,
backend,
self.events_tx.clone(),
);
self.tabs[ix] =
TerminalTab::new_ssh(tab_id.clone(), &session, backend, self.events_tx.clone());
// Find group to restart SFTP
if let Some(group) = self.tab_groups.iter().find(|g| g.pane_root.contains(&tab_id)) {
if let Some(group) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&tab_id))
{
groups_to_restart_sftp.insert(group.id.clone());
}
}
@@ -513,8 +543,12 @@ impl Ashell {
for group_id in groups_to_restart_sftp {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == group_id) {
// Use the session of any tab in that group
let group_session = self.tabs.iter().find(|t| group.pane_root.contains(&t.id) && t.session.is_some()).and_then(|t| t.session.clone());
let group_session = self
.tabs
.iter()
.find(|t| group.pane_root.contains(&t.id) && t.session.is_some())
.and_then(|t| t.session.clone());
if let Some(session) = group_session {
if let Some(old_handle) = self.sftp_handles.remove(&group.id) {
old_handle.close();
@@ -526,7 +560,7 @@ impl Ashell {
self.events_tx.clone(),
);
self.sftp_handles.insert(group.id.clone(), sftp_handle);
if let Some(sftp) = group.sftp.as_mut() {
sftp.status = rust_i18n::t!("sftp_connecting").to_string();
}
@@ -549,7 +583,9 @@ impl Ashell {
return;
}
self.connection_progress = None;
let tabs_to_close: Vec<_> = self.tabs.iter()
let tabs_to_close: Vec<_> = self
.tabs
.iter()
.filter(|tab| !tab.connected && tab.session.is_some())
.map(|tab| tab.id.clone())
.collect();
@@ -569,7 +605,10 @@ impl Ashell {
}
self.active_tab = Some(id.clone());
// Find which group this tab belongs to and restore its pane_root
let tab_group = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&id));
let tab_group = self
.tab_groups
.iter_mut()
.find(|g| g.pane_root.contains(&id));
if let Some(group) = tab_group {
self.pane_root = group.pane_root.clone();
self.active_group = Some(group.id.clone());
@@ -584,7 +623,12 @@ impl Ashell {
}
if self.tabs.iter().any(|t| t.id == id) {
if let Some(session_id) = self.active_session_id() {
if let Some(index) = self.config.sessions().iter().position(|s| s.id == session_id) {
if let Some(index) = self
.config
.sessions()
.iter()
.position(|s| s.id == session_id)
{
self.saved_scroll_handle.scroll_to_item(index);
}
}
@@ -600,10 +644,16 @@ impl Ashell {
}
pub(crate) fn handle_tab_close(&mut self, id: String) {
let group_ix = self.tab_groups.iter().position(|g| g.pane_root.contains(&id));
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
tracing::info!("[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);
@@ -616,7 +666,9 @@ impl Ashell {
let is_group_close = pane_ids.len() <= 1;
tracing::info!(
"[handle_tab_close] id='{}' group_panes={:?} is_group_close={}",
id, pane_ids_str, is_group_close
id,
pane_ids_str,
is_group_close
);
let was_active = self.active_tab.as_deref() == Some(id.as_str());
@@ -635,16 +687,31 @@ impl Ashell {
let all_groups = &self.tab_groups;
if let Some(pos) = all_groups.iter().position(|g| g.id == group.id) {
if pos > 0 {
next_active_id = all_groups[pos - 1].pane_root.tab_ids().first().copied().map(String::from);
next_active_id = all_groups[pos - 1]
.pane_root
.tab_ids()
.first()
.copied()
.map(String::from);
} else if pos + 1 < all_groups.len() {
next_active_id = all_groups[pos + 1].pane_root.tab_ids().first().copied().map(String::from);
next_active_id = all_groups[pos + 1]
.pane_root
.tab_ids()
.first()
.copied()
.map(String::from);
}
}
}
}
if is_group_close {
// Close all tabs in the group
let tab_ids: Vec<String> = group.pane_root.tab_ids().iter().map(|s| s.to_string()).collect();
let tab_ids: Vec<String> = group
.pane_root
.tab_ids()
.iter()
.map(|s| s.to_string())
.collect();
for tab_id in &tab_ids {
if let Some(ix) = self.tabs.iter().position(|tab| tab.id == *tab_id) {
self.tabs[ix].backend.send(BackendCommand::Close);
@@ -662,7 +729,11 @@ impl Ashell {
self.tabs[ix].backend.send(BackendCommand::Close);
self.tabs.retain(|t| t.id != id);
}
if let Some(g) = self.tab_groups.iter_mut().find(|g| g.pane_root.contains(&id)) {
if let Some(g) = self
.tab_groups
.iter_mut()
.find(|g| g.pane_root.contains(&id))
{
g.pane_root.remove_tab(&id);
}
self.pane_root.remove_tab(&id);
@@ -687,9 +758,6 @@ impl Ashell {
return;
}
if was_active
|| self
.active_tab
@@ -698,12 +766,20 @@ impl Ashell {
{
// Activate next available pane
let new_id = next_active_id.or_else(|| {
self.pane_root.tab_ids().first().copied().map(String::from)
self.pane_root
.tab_ids()
.first()
.copied()
.map(String::from)
.or_else(|| self.tabs.first().map(|t| t.id.clone()))
});
if let Some(new_id) = new_id {
self.active_tab = Some(new_id.clone());
if let Some(g) = self.tab_groups.iter().find(|g| g.pane_root.contains(&new_id)) {
if let Some(g) = self
.tab_groups
.iter()
.find(|g| g.pane_root.contains(&new_id))
{
self.active_group = Some(g.id.clone());
self.pane_root = g.pane_root.clone();
}
@@ -799,28 +875,33 @@ impl Ashell {
.first()
.map(|size| size.as_f32())
.unwrap_or(SIDEBAR_WIDTH);
let terminal_height = self
.body_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
let width = (viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
.max(self.terminal_cell_width());
let height = (terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height());
// Use the whole terminal panel bounds for PTY sizing.
// Individual pane bounds are smaller when split, so using them here
// would shrink the root PTY dimensions a second time.
let (width, height) = if let Some(bounds) = self.terminal_panel_bounds {
(bounds.size.width.as_f32(), bounds.size.height.as_f32())
} else {
let terminal_height = self
.body_panels
.read(cx)
.sizes()
.first()
.map(|size| size.as_f32())
.unwrap_or(viewport.height.as_f32() - TAB_BAR_HEIGHT - 248.0);
(
(viewport.width.as_f32() - sidebar_width - TERMINAL_PADDING_X - 8.0)
.max(self.terminal_cell_width()),
(terminal_height - TERMINAL_PADDING_Y).max(self.terminal_line_height()),
)
};
let total_cols = (width / self.terminal_cell_width()).floor().max(1.0) as u16;
let total_rows = (height / self.terminal_line_height()).floor().max(1.0) as u16;
Self::resize_pane_tree(&mut self.tabs, &self.pane_root, total_cols, total_rows);
}
fn resize_pane_tree(
tabs: &mut [TerminalTab],
layout: &PaneLayout,
cols: u16,
rows: u16,
) {
fn resize_pane_tree(tabs: &mut [TerminalTab], layout: &PaneLayout, cols: u16, rows: u16) {
match layout {
PaneLayout::Single(id) => {
if let Some(tab) = tabs.iter_mut().find(|t| t.id == *id) {
@@ -905,12 +986,7 @@ impl Ashell {
self.events_tx.clone(),
);
self.sftp_handles.insert(new_id.clone(), sftp_handle);
TerminalTab::new_ssh(
new_id.clone(),
&session,
backend,
self.events_tx.clone(),
)
TerminalTab::new_ssh(new_id.clone(), &session, backend, self.events_tx.clone())
}
};
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
@@ -939,7 +1015,8 @@ impl Ashell {
_ => return,
};
self.pane_root.replace_at(&self.focused_pane_path, split_layout);
self.pane_root
.replace_at(&self.focused_pane_path, split_layout);
self.sync_pane_root_to_group();
// Update focused_pane_path: the new pane is at the indicated child index
let parent_path = self.focused_pane_path.clone();
@@ -1001,7 +1078,11 @@ impl Ashell {
}
}
fn find_adjacent_pane(layout: &PaneLayout, path: &[usize], direction: &str) -> Option<Vec<usize>> {
fn find_adjacent_pane(
layout: &PaneLayout,
path: &[usize],
direction: &str,
) -> Option<Vec<usize>> {
if path.is_empty() {
return None;
}
@@ -1023,7 +1104,11 @@ impl Ashell {
if path.len() == 1 {
// Direct child level
if moves_in_this_split {
let delta: i32 = if direction == "up" || direction == "left" { -1 } else { 1 };
let delta: i32 = if direction == "up" || direction == "left" {
-1
} else {
1
};
let new_idx = idx as i32 + delta;
if new_idx >= 0 && (new_idx as usize) < children.len() {
let mut path = vec![new_idx as usize];
@@ -1037,17 +1122,26 @@ impl Ashell {
}
} else {
// Recurse into child first
if let Some(mut child_path) = Self::find_adjacent_pane(&children[idx], &path[1..], direction) {
if let Some(mut child_path) =
Self::find_adjacent_pane(&children[idx], &path[1..], direction)
{
child_path.insert(0, idx);
Some(child_path)
} else if moves_in_this_split {
// Try sibling at this level
let delta: i32 = if direction == "up" || direction == "left" { -1 } else { 1 };
let delta: i32 = if direction == "up" || direction == "left" {
-1
} else {
1
};
let new_idx = idx as i32 + delta;
if new_idx >= 0 && (new_idx as usize) < children.len() {
let inner_idx = *path.get(1).unwrap_or(&0);
let mut path = vec![new_idx as usize];
path.extend(Self::leaf_at_index(&children[new_idx as usize], inner_idx));
path.extend(Self::leaf_at_index(
&children[new_idx as usize],
inner_idx,
));
Some(path)
} else {
None
@@ -1068,7 +1162,11 @@ impl Ashell {
) {
// Save current group state
if let Some(current_group_id) = self.active_group.clone() {
if let Some(group) = self.tab_groups.iter_mut().find(|g| g.id == current_group_id) {
if let Some(group) = self
.tab_groups
.iter_mut()
.find(|g| g.id == current_group_id)
{
group.pane_root = self.pane_root.clone();
}
}
@@ -1111,7 +1209,8 @@ impl Ashell {
}
// Check if current system_tab_id is valid in this group
let is_current_valid = self.system_tab_id
let is_current_valid = self
.system_tab_id
.as_ref()
.map_or(false, |id| group_ssh_tabs.contains(id));
@@ -1187,15 +1286,20 @@ impl Ashell {
match (layout, path) {
(PaneLayout::Horizontal(_, _), []) => true,
(PaneLayout::Vertical(_, _), []) => false,
(PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _), [first, rest @ ..]) => {
children.get(*first).map_or(false, |c| Self::is_layout_horizontal_at(c, rest))
}
(
PaneLayout::Horizontal(children, _) | PaneLayout::Vertical(children, _),
[first, rest @ ..],
) => children
.get(*first)
.map_or(false, |c| Self::is_layout_horizontal_at(c, rest)),
_ => false,
}
}
fn adjust_split_ratio(layout: &mut PaneLayout, path: &[usize], _child_idx: usize, delta: f32) {
if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) = layout {
if let PaneLayout::Horizontal(children, ratio) | PaneLayout::Vertical(children, ratio) =
layout
{
if path.is_empty() {
*ratio = (*ratio + delta).clamp(0.1, 0.9);
} else {
+126 -30
View File
@@ -258,7 +258,7 @@ async fn run_sftp(
.canonicalize(".")
.await
.unwrap_or_else(|_| "/".to_string());
let _ = events.send(BackendEvent::SftpHome {
tab_id: tab_id.clone(),
home: home.clone(),
@@ -379,16 +379,28 @@ async fn run_sftp(
}
Err(err) => {
let err_msg = format!("{err:#}");
let is_cancelled = err_msg.contains("transfer cancelled");
let state = if is_cancelled {
crate::terminal::TransferState::Interrupted(
"User cancelled".to_string(),
)
} else {
crate::terminal::TransferState::Failed(err_msg.clone())
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("download_failed", err = err_msg.clone()).to_string(),
text: if is_cancelled {
"Transmission cancelled".to_string()
} else {
t!("download_failed", err = err_msg.clone()).to_string()
},
});
let _ = events_clone.send(BackendEvent::TransferProgress {
tab_id: tab_id_clone,
id: id.clone(),
transferred: 0,
total: None,
state: crate::terminal::TransferState::Failed(err_msg),
state,
});
}
}
@@ -480,16 +492,28 @@ async fn run_sftp(
}
Err(err) => {
let err_msg = format!("{err:#}");
let is_cancelled = err_msg.contains("transfer cancelled");
let state = if is_cancelled {
crate::terminal::TransferState::Interrupted(
"User cancelled".to_string(),
)
} else {
crate::terminal::TransferState::Failed(err_msg.clone())
};
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: t!("upload_failed", err = err_msg.clone()).to_string(),
text: if is_cancelled {
"Transmission cancelled".to_string()
} else {
t!("upload_failed", err = err_msg.clone()).to_string()
},
});
let _ = events_clone.send(BackendEvent::TransferProgress {
tab_id: tab_id_clone,
id: id.clone(),
transferred: 0,
total: None,
state: crate::terminal::TransferState::Failed(err_msg),
state,
});
}
}
@@ -575,10 +599,13 @@ async fn run_sftp(
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
while let Ok(_) = rx.try_recv() {} // drain pending
if commands_tx_clone.send(SftpCommand::UploadEditedFile {
local_path: local_path.to_string_lossy().to_string(),
remote_path: remote_path.clone(),
}).is_err() {
if commands_tx_clone
.send(SftpCommand::UploadEditedFile {
local_path: local_path.to_string_lossy().to_string(),
remote_path: remote_path.clone(),
})
.is_err()
{
break;
}
}
@@ -622,7 +649,11 @@ async fn run_sftp(
let now = chrono::Local::now().format("%H:%M:%S");
let _ = events_clone.send(BackendEvent::SftpStatus {
tab_id: tab_id_clone.clone(),
text: format!("{} ({})", t!("auto_saved_and_uploaded", base = base_name(&remote_path)), now),
text: format!(
"{} ({})",
t!("auto_saved_and_uploaded", base = base_name(&remote_path)),
now
),
});
}
Err(err) => {
@@ -642,16 +673,17 @@ async fn run_sftp(
} else {
path.clone()
};
tracing::info!("[sftp] creating directory: '{}'", actual_path);
match sftp.create_dir(&actual_path).await {
Ok(_) => {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: t!("create_folder_success", name = base_name(&actual_path)).to_string(),
text: t!("create_folder_success", name = base_name(&actual_path))
.to_string(),
});
// Re-fetch the parent directory to show the newly created folder
if let Some(parent) = parent_dir(&actual_path) {
let _ = commands_tx.send(SftpCommand::ListDir(parent));
@@ -741,21 +773,27 @@ fn recursive_delete<'a>(
continue;
}
let child_path = crate::sftp::join_remote(&path, &name);
let meta = entry.metadata();
let permissions = meta.permissions.unwrap_or(0);
let is_dir = (permissions & 0o170_000) == 0o040_000;
if is_dir {
recursive_delete(sftp, child_path).await?;
} else {
sftp.remove_file(&child_path).await.with_context(|| format!("Failed to delete file {child_path}"))?;
sftp.remove_file(&child_path)
.await
.with_context(|| format!("Failed to delete file {child_path}"))?;
}
}
sftp.remove_dir(&path).await.with_context(|| format!("Failed to delete dir {path}"))?;
sftp.remove_dir(&path)
.await
.with_context(|| format!("Failed to delete dir {path}"))?;
}
Err(_) => {
sftp.remove_file(&path).await.with_context(|| format!("Failed to delete {path}"))?;
sftp.remove_file(&path)
.await
.with_context(|| format!("Failed to delete {path}"))?;
}
}
Ok(())
@@ -809,7 +847,9 @@ async fn connect_and_authenticate(
break;
}
Ok(false) => {
tracing::debug!("[sftp] public key auth failed with algorithm, trying next");
tracing::debug!(
"[sftp] public key auth failed with algorithm, trying next"
);
continue;
}
Err(e) => {
@@ -819,7 +859,12 @@ async fn connect_and_authenticate(
}
}
if !success {
return Err(anyhow!("public key authentication failed for {}@{}:{}", session.user, session.host, session.port));
return Err(anyhow!(
"public key authentication failed for {}@{}:{}",
session.user,
session.host,
session.port
));
}
success
}
@@ -847,6 +892,8 @@ async fn connect_and_authenticate(
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let inline_key = normalize_inline_private_key(&session.private_key_inline);
let key_path = expand_key_path(session.private_key_path.trim());
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let has_inline = !inline_key.is_empty();
let has_path = key_path.is_some();
@@ -857,14 +904,14 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
let mut errors = Vec::new();
if has_inline {
match decode_secret_key(&inline_key, None) {
match decode_secret_key(&inline_key, passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("decode private key content: {err}")),
}
}
if let Some(path) = key_path {
match load_secret_key(path.as_path(), None) {
match load_secret_key(path.as_path(), passphrase) {
Ok(key) => return Ok(key),
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
}
@@ -894,7 +941,9 @@ fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashA
}
if algs.is_empty() {
return Err(anyhow!("Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"));
return Err(anyhow!(
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
));
}
Ok(algs)
@@ -1100,6 +1149,12 @@ async fn download_path_impl(
.await
.with_context(|| format!("create {}", local_dir.display()))?;
// Check for cancellation after initial setup
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
let metadata = sftp
.metadata(remote)
.await
@@ -1193,7 +1248,15 @@ async fn download_remote_directory_archive(
base_name(remote_dir),
Uuid::new_v4()
);
// Check for cancellation before creating remote archive
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
create_remote_archive(handle, remote_dir, &remote_archive).await?;
let local_extract_root = local_archive
.parent()
.unwrap_or_else(|| Path::new("."))
@@ -1299,6 +1362,12 @@ async fn upload_paths_impl(
tab_id: &str,
id: &str,
) -> Result<String> {
// Check for cancellation before starting
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
create_remote_dir_all(sftp, remote_dir).await?;
let mut file_count = 0usize;
let mut folder_count = 0usize;
@@ -1351,9 +1420,19 @@ async fn upload_paths_impl(
}
}
// Check for cancellation before creating directories
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
// Create directories sequentially first
for dir in dirs_to_create {
// Check for cancellation between each directory creation
let state = flag.0.load(Ordering::SeqCst);
if state == 2 {
return Err(anyhow::anyhow!("transfer cancelled"));
}
create_remote_dir_all(sftp, &dir).await?;
}
@@ -1521,14 +1600,31 @@ async fn exec_remote_command(
let mut stderr = Vec::new();
let mut stdout = Vec::new();
let mut exit_status = None;
while let Some(msg) = channel.wait().await {
match msg {
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
russh::ChannelMsg::Close => break,
_ => {}
// Add timeout to prevent indefinite blocking (300 seconds = 5 minutes)
let timeout = tokio::time::Duration::from_secs(300);
let result = tokio::time::timeout(timeout, async {
loop {
// Yield to allow cancellation
tokio::task::yield_now().await;
if let Some(msg) = channel.wait().await {
match msg {
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
russh::ChannelMsg::Close => break,
_ => {}
}
} else {
break;
}
}
})
.await;
if result.is_err() {
return Err(anyhow!("remote command timeout: {command}"));
}
match exit_status.unwrap_or(0) {
+72 -14
View File
@@ -8,8 +8,14 @@ use crate::{
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
let ext = std::path::Path::new(&lower).extension().and_then(|s| s.to_str()).unwrap_or("");
let known_exts = ["txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", "css", "md", "toml", "csv", "log", "cfg"];
let ext = std::path::Path::new(&lower)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let known_exts = [
"txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html",
"css", "md", "toml", "csv", "log", "cfg",
];
if known_exts.contains(&ext) {
return true;
}
@@ -22,19 +28,23 @@ pub(crate) fn is_editable_text_file(filename: &str) -> bool {
impl Ashell {
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
self.active_group.as_ref()
self.active_group
.as_ref()
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
.and_then(|g| g.sftp.as_ref())
}
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
let active_id = self.active_group.clone()?;
self.tab_groups.iter_mut().find(|g| g.id == active_id)
self.tab_groups
.iter_mut()
.find(|g| g.id == active_id)
.and_then(|g| g.sftp.as_mut())
}
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
self.active_group.as_ref()
self.active_group
.as_ref()
.and_then(|id| self.sftp_handles.get(id))
}
@@ -122,7 +132,11 @@ impl Ashell {
}
}
pub(crate) fn trigger_sftp_context_download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn trigger_sftp_context_download(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
@@ -161,8 +175,16 @@ impl Ashell {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating download of '{}' to '{}'", remote_path, local_path);
tracing::info!(
"[sftp] initiating download of '{}' to '{}'",
remote_path,
local_path
);
handle.download(remote_path, local_path);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
@@ -197,8 +219,16 @@ impl Ashell {
Ok(Ok(Some(mut paths))) => {
if let Some(file) = paths.pop() {
let local_path = file.to_string_lossy().to_string();
tracing::info!("[sftp] initiating upload of file '{}' to '{}'", local_path, remote_dir);
tracing::info!(
"[sftp] initiating upload of file '{}' to '{}'",
local_path,
remote_dir
);
handle.upload_paths(vec![local_path], remote_dir);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
@@ -233,8 +263,16 @@ impl Ashell {
Ok(Ok(Some(mut paths))) => {
if let Some(folder) = paths.pop() {
let local_path = folder.to_string_lossy().to_string();
tracing::info!("[sftp] initiating upload of folder '{}' to '{}'", local_path, remote_dir);
tracing::info!(
"[sftp] initiating upload of folder '{}' to '{}'",
local_path,
remote_dir
);
handle.upload_paths(vec![local_path], remote_dir);
this.update(cx, |this, cx| {
this.show_transfers_dialog = true;
cx.notify();
})?;
}
}
Ok(Err(err)) => {
@@ -250,7 +288,12 @@ impl Ashell {
.detach();
}
pub(crate) fn toggle_sftp_entry(&mut self, path: String, checked: bool, cx: &mut Context<Self>) {
pub(crate) fn toggle_sftp_entry(
&mut self,
path: String,
checked: bool,
cx: &mut Context<Self>,
) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
sftp.selected_entries.insert(path);
@@ -275,7 +318,11 @@ impl Ashell {
}
}
pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub(crate) fn download_selected_sftp_entries(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(sftp) = self.active_sftp() else {
return;
};
@@ -299,7 +346,11 @@ 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);
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,
@@ -311,6 +362,7 @@ impl Ashell {
if let Some(sftp_mut) = this.active_sftp_mut() {
sftp_mut.selected_entries.clear();
}
this.show_transfers_dialog = true;
cx.notify();
});
}
@@ -320,17 +372,23 @@ impl Ashell {
.detach();
}
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, _cx: &mut Context<Self>) {
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, cx: &mut Context<Self>) {
if paths.is_empty() {
return;
}
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);
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(),
});
self.show_transfers_dialog = true;
cx.notify();
}
}
}
+126 -40
View File
@@ -3,9 +3,11 @@ 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
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,
}
}
@@ -101,85 +103,164 @@ pub fn paint_custom_block(
// 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);
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);
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
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);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2518 => { // Up + Left
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
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);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2524 => { // Vertical + Left
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
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);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
}
0x2534 => { // Horizontal + Up
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)
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);
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);
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
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);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x251B => { // Heavy Up + Left
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
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);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x252B => { // Heavy Vertical + Left
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
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);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
}
0x253B => { // Heavy Horizontal + Up
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)
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
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));
@@ -187,7 +268,8 @@ pub fn paint_custom_block(
window.paint_path(path, color);
painted = true;
}
0xE0B2 => { // Leftward Solid Arrow
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));
@@ -195,7 +277,8 @@ pub fn paint_custom_block(
window.paint_path(path, color);
painted = true;
}
0xE0B1 => { // Rightward Line Arrow
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));
@@ -211,7 +294,8 @@ pub fn paint_custom_block(
window.paint_path(p2, color);
painted = true;
}
0xE0B3 => { // Leftward Line Arrow
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));
@@ -229,14 +313,16 @@ pub fn paint_custom_block(
}
// Half arches (Powerline rounded)
0xE0B4 => { // Rightward Solid Semicircle
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
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));
+25 -9
View File
@@ -11,8 +11,8 @@ use gpui::{
use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::{RenderSnapshot, ViewportSelection};
use crate::terminal::custom_blocks::{paint_custom_block, is_custom_block_supported};
#[derive(Clone, Copy)]
struct TerminalMetrics {
@@ -230,9 +230,12 @@ impl InputHandler for TerminalInputHandler {
_window: &mut Window,
cx: &mut App,
) -> Option<Bounds<Pixels>> {
self.view
.read(cx)
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds, self.cell_width, self.line_height)
self.view.read(cx).terminal_ime_bounds_for_range(
range_utf16,
self.element_bounds,
self.cell_width,
self.line_height,
)
}
fn character_index_for_point(
@@ -342,7 +345,10 @@ impl TerminalElement {
}
}
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
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();
@@ -364,7 +370,11 @@ impl TerminalElement {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 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) {
@@ -395,7 +405,11 @@ impl TerminalElement {
c: cell.c,
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
continue;
@@ -521,8 +535,10 @@ impl Element for TerminalElement {
}
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();
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,
+11 -6
View File
@@ -8,9 +8,8 @@ use gpui::{
};
use crate::{
Ashell,
Ashell, TerminalBacktabKey, TerminalTabKey,
terminal::{BackendCommand, encode_key},
TerminalBacktabKey, TerminalTabKey,
};
impl Ashell {
@@ -287,7 +286,11 @@ impl Ashell {
}
}
pub(crate) fn begin_terminal_selection(&mut self, event: &MouseDownEvent, cx: &mut Context<Self>) {
pub(crate) fn begin_terminal_selection(
&mut self,
event: &MouseDownEvent,
cx: &mut Context<Self>,
) {
let click_count = event.click_count.max(1);
let selection_type = match click_count {
1 => SelectionType::Simple,
@@ -419,7 +422,7 @@ impl Ashell {
}
let mode = tab.term.mode();
let is_mouse_tracking = mode.intersects(
alacritty_terminal::term::TermMode::MOUSE_REPORT_CLICK
| alacritty_terminal::term::TermMode::MOUSE_MOTION
@@ -452,7 +455,8 @@ impl Ashell {
}
}
if !bytes.is_empty() {
tab.backend.send(crate::terminal::BackendCommand::Input(bytes));
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
}
}
window.prevent_default();
@@ -466,7 +470,8 @@ impl Ashell {
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
}
if !bytes.is_empty() {
tab.backend.send(crate::terminal::BackendCommand::Input(bytes));
tab.backend
.send(crate::terminal::BackendCommand::Input(bytes));
}
window.prevent_default();
cx.stop_propagation();
+44 -3
View File
@@ -1,6 +1,6 @@
pub mod custom_blocks;
pub mod element;
pub mod input;
pub mod custom_blocks;
use std::sync::mpsc::Sender;
@@ -244,7 +244,11 @@ impl TerminalTab {
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);
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 });
}
@@ -674,15 +678,52 @@ pub enum TransferType {
Download,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum TransferState {
Running,
Paused,
Completed,
Failed(String),
Interrupted(String), // 中断传输:包含原因(例如 "User cancelled", "Network timeout")
Zombie(String), // 程序重启后残留的 Running/Paused 任务
// 兼容 v0.3.11 -> v0.4.x:旧配置里曾保存过 `Cancelled`,
// 新版本改成了带原因的状态,因此要手动接住旧枚举值。
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
enum TransferStateCompat {
Running,
Paused,
Completed,
Failed(String),
Interrupted(String),
Zombie(String),
Cancelled,
}
impl From<TransferStateCompat> for TransferState {
fn from(value: TransferStateCompat) -> Self {
match value {
TransferStateCompat::Running => Self::Running,
TransferStateCompat::Paused => Self::Paused,
TransferStateCompat::Completed => Self::Completed,
TransferStateCompat::Failed(reason) => Self::Failed(reason),
TransferStateCompat::Interrupted(reason) => Self::Interrupted(reason),
TransferStateCompat::Zombie(reason) => Self::Zombie(reason),
TransferStateCompat::Cancelled => Self::Interrupted("Cancelled".to_string()),
}
}
}
impl<'de> serde::Deserialize<'de> for TransferState {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
TransferStateCompat::deserialize(deserializer).map(Into::into)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TransferInfo {
pub id: String,