Compare commits

..
29 Commits
Author SHA1 Message Date
TomZz 7ae44ab9a7 ci: automate homebrew cask release for macos 2026-06-17 05:59:05 +08:00
TomZz 9c43b2dde7 chore: release v0.4.4 2026-06-17 05:00:51 +08:00
TomZz e6134bc0d1 fix: sftp panel status bar layout bug, update localized strings and add Open Transfer History shortcut 2026-06-17 04:58:27 +08:00
TomZz 96cd85a8d3 feat: persist sidebar and sftp panel visibility states 2026-06-17 03:00:44 +08:00
TomZz f45c2ed60f chore: release 2026-06-17 02:46:36 +08:00
TomZz 8a8f280401 feat: group keybindings and set keybindings page as default in settings 2026-06-17 02:43:10 +08:00
TomZz dc006f7144 fix: explicitly set mac quit mode to LastWindowClosed 2026-06-17 02:16:05 +08:00
TomZz daaebe4c2b feat: add sidebar collapsible feature 2026-06-17 00:43:39 +08:00
TomZz 2a37f45ba2 Merge pull request #35 from ancion/main
fix splitter resize bug and sftp status bar show file transfer progress
2026-06-16 22:54:07 +08:00
ancion ee8c18844c fix: sftp status not show sometimes and file transfer progress bar 2026-06-16 18:49:48 +08:00
ancion c5b09628b3 fix: panel splitter can drag to resize 2026-06-16 18:49:48 +08:00
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
TomZz c3201e7c30 chore: bump version to v0.3.11 2026-06-15 01:14:48 +08:00
TomZz 2d5062ed75 feat: implement pixel-perfect custom rendering for block elements, powerline and box drawing
- Render block elements (U+2580-U+259F), box drawing lines, and powerline characters using precise GPUI vector paths instead of fonts.

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

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

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

This fix correctly interprets the terminal cell flags during layout generation: if a cell possesses `Flags::WIDE_CHAR`, it generates a background rectangle spanning 2 cells (`cells: 2`). As a result, the highlight now matches the exact bounds that the text occupies in the terminal grid, cleanly fixing the partial highlighting of Chinese text.
2026-06-14 23:19:33 +08:00
TomZz 8ab4b43825 style: update settings button icon and size 2026-06-14 19:41:55 +08:00
TomZz bbf843dde6 feat: settings dialog using Settings component #19 2026-06-14 19:34:06 +08:00
TomZz bc6af2e213 fix: close local terminal tab reliably when process exits on Windows 2026-06-14 16:31:30 +08:00
26 changed files with 3919 additions and 1523 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 }}
+78
View File
@@ -168,3 +168,81 @@ jobs:
files: dist/**/*
generate_release_notes: true
fail_on_unmatched_files: true
cask:
name: Update Homebrew Cask
needs: publish
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Checkout Homebrew Tap
uses: actions/checkout@v4
with:
repository: rust-kotlin/homebrew-taps
token: ${{ secrets.TAP_GITHUB_TOKEN }}
path: tap
- name: Download all macOS artifacts
uses: actions/download-artifact@v4
with:
pattern: macos-*
path: dist
- name: Update Cask
run: |
VERSION=${GITHUB_REF#refs/tags/v}
SHA_ARM=$(sha256sum dist/macos-aarch64/*.zip | cut -d' ' -f1)
SHA_INTEL=$(sha256sum dist/macos-x86_64/*.zip | cut -d' ' -f1)
mkdir -p tap/Casks
CASK_FILE="tap/Casks/ashell.rb"
cat > "$CASK_FILE" <<EOF
cask "ashell" do
version "${VERSION}"
on_arm do
sha256 "${SHA_ARM}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-aarch64.zip"
end
on_intel do
sha256 "${SHA_INTEL}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-x86_64.zip"
end
name "ashell"
desc "ashell is a modern, fast, GPUI Component-based desktop terminal client written in Rust."
homepage "https://github.com/rust-kotlin/ashell"
app "ashell.app"
postflight do
puts "Removing quarantine flag from ashell.app..."
system_command "/usr/bin/xattr",
args: ["-rd", "com.apple.quarantine", "#{appdir}/ashell.app"],
sudo: true
end
caveats <<~EOS
ashell is not notarized. The installation includes a postflight script
that removes the 'com.apple.quarantine' attribute from the application
to allow it to run. This will require your administrator password.
EOS
end
EOF
# Remove the 10 leading spaces added for YAML formatting
sed -i 's/^ //' "$CASK_FILE"
- name: Commit and Push
run: |
VERSION=${GITHUB_REF#refs/tags/v}
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [[ -n $(git status -s) ]]; then
git add Casks/ashell.rb
git commit -m "chore: bump ashell to v${VERSION}"
git push
else
echo "No changes to commit"
fi
Generated
+1 -1
View File
@@ -303,7 +303,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.1.0"
version = "0.4.4"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.1.0"
version = "0.4.4"
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 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
## 运行
+42 -3
View File
@@ -67,7 +67,6 @@ terminal_font_size: "Terminal Font Size"
system_default: "System Default"
ui_font_family: "UI Font"
terminal_font_family: "Terminal Font"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
monitoring_position: "Monitoring Position"
position_bottom: "Bottom"
position_sidebar: "Sidebar"
@@ -95,7 +94,7 @@ n_folders: "%{folders} folders"
n_files_and_folders: "%{files} files and %{folders} folders"
reset: "Reset"
reset_layout: "Reset Default Layout"
reset_layout_success: "Layout reset. Window size takes effect on restart."
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
transfers: "Transfers"
no_transfers_yet: "No transfers yet"
@@ -104,14 +103,54 @@ 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"
right_click_copy_paste: "Right-click Copy/Paste"
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to copy/paste."
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
edit_file: "Edit File"
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
scroll: "Scroll ↓"
clone: "Clone"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_other: "Other"
settings_group_keybind_general: "General"
settings_group_keybind_zoom: "Zoom"
settings_group_keybind_focus: "Focus"
settings_group_keybind_panel: "Panel"
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_open_transfers: "Open Transfer History"
files_downloading: "%{count} files downloading"
files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
settings_toggle_sidebar: "Toggle Sidebar"
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'
+43 -4
View File
@@ -23,7 +23,6 @@ system_path_warning: "警告:您即将删除系统关键路径!这可能会
delete_success: "成功删除 %{count} 个项"
delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
cancel: "取消"
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
@@ -69,7 +68,6 @@ terminal_font_size: "终端字体大小"
system_default: "系统默认"
ui_font_family: "界面字体"
terminal_font_family: "终端字体"
theme_management_hint: "深色/浅色模式以及主题配色由顶部栏的样式按钮统一管理。"
monitoring_position: "系统监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
@@ -97,7 +95,7 @@ n_folders: "%{folders} 个文件夹"
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
reset: "重置"
reset_layout: "恢复默认布局"
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
transfers: "传输记录"
no_transfers_yet: "暂无传输记录"
@@ -106,14 +104,55 @@ paused: "已暂停"
completed: "已完成"
failed: "失败"
cancelled: "已取消"
interrupted: "已中断"
session: "会话"
zombie: "僵尸任务"
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_other: "其它"
settings_group_keybind_general: "常规"
settings_group_keybind_zoom: "缩放"
settings_group_keybind_focus: "焦点"
settings_group_keybind_panel: "面板"
theme: "主题"
theme_mode: "主题模式"
clear_transfers: "清空记录"
transfers_limit: " (最多保留 100 条)"
press_new_key: "请按下新的快捷键..."
settings_open_settings: "打开设置"
settings_open_session: "打开会话"
settings_open_transfers: "打开传输历史"
files_downloading: "%{count} 个文件正在下载"
files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
settings_toggle_sidebar: "缩放侧边栏"
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}」使用,请选择其他快捷键'
+1
View File
@@ -1,6 +1,7 @@
pub(crate) const DEFAULT_COLS: u16 = 100;
pub(crate) const DEFAULT_ROWS: u16 = 30;
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
pub(crate) const COLLAPSED_SIDEBAR_WIDTH: f32 = 52.0;
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
+1000 -686
View File
File diff suppressed because it is too large Load Diff
+391
View File
@@ -0,0 +1,391 @@
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,
OpenTransfers,
NewSsh,
ToggleSidebar,
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: "OpenTransfers",
label_key: "settings_open_transfers",
default_suffix: "t",
},
WorkspaceAction {
id: "NewSsh",
label_key: "settings_new_ssh",
default_suffix: "n",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
default_suffix: "s",
},
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!("OpenTransfers", crate::OpenTransfers);
unbind_action!("NewSsh", crate::NewSsh);
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
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!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
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_groups(view: &Entity<Ashell>, cx: &mut App) -> Vec<SettingGroup> {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh"],
),
(
"settings_group_keybind_zoom",
vec!["ToggleSidebar", "ToggleSftpZoom"],
),
(
"settings_group_keybind_focus",
vec![
"FocusPaneLeft",
"FocusPaneRight",
"FocusPaneUp",
"FocusPaneDown",
],
),
(
"settings_group_keybind_panel",
vec![
"SplitPaneLeft",
"SplitPaneRight",
"SplitPaneUp",
"SplitPaneDown",
"ClosePane",
],
),
];
let mut result = Vec::new();
for (group_label, action_ids) in groups {
let mut group = SettingGroup::new().title(t!(group_label).to_string());
for action_id in action_ids {
let action = WORKSPACE_ACTIONS
.iter()
.find(|a| a.id == action_id)
.expect("action exists");
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);
}
result.push(group);
}
result
}
}
+119 -39
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,7 +189,13 @@ impl ScrollbarHandle for TerminalScrollbarHandle {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DialogKind {
Settings,
SessionSelector,
Transfers,
NewSsh,
}
pub(crate) struct Ashell {
pub(crate) focus_handle: FocusHandle,
@@ -199,6 +207,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>,
@@ -217,6 +226,7 @@ pub(crate) struct Ashell {
pub(crate) selector_selection: usize,
pub(crate) workspace_panels: Entity<ResizableState>,
pub(crate) body_panels: Entity<ResizableState>,
pub(crate) is_layout_reset: bool,
pub(crate) terminal_scrollbars: HashMap<String, TerminalScrollbarHandle>,
pub(crate) remote_files_scroll_handle: UniformListScrollHandle,
pub(crate) disk_scroll_handle: gpui::ScrollHandle,
@@ -236,16 +246,25 @@ 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)
pub(crate) drag_split_origin: Option<gpui::Point<Pixels>>,
pub(crate) terminal_marked_text: Option<String>,
pub(crate) sftp_panel_minimized: bool,
pub(crate) sidebar_collapsed: bool,
pub(crate) collapsed_saved_scroll_handle: gpui::ScrollHandle,
pub(crate) prev_monitoring_size: Option<Pixels>,
pub(crate) status: SharedString,
pub(crate) config: ConfigStore,
pub(crate) system_sampler: SystemSampler,
pub(crate) recording_action: Option<String>,
pub(crate) active_dialog: Option<DialogKind>,
/// 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>,
@@ -255,7 +274,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>,
@@ -286,6 +305,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 =
@@ -305,8 +344,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),
@@ -316,6 +361,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),
];
@@ -331,12 +377,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,
@@ -378,6 +419,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,
@@ -395,9 +437,11 @@ 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,
is_layout_reset: false,
terminal_scrollbars: HashMap::new(),
remote_files_scroll_handle: UniformListScrollHandle::new(),
disk_scroll_handle: gpui::ScrollHandle::new(),
@@ -412,7 +456,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(),
@@ -421,10 +478,16 @@ impl Ashell {
dragging_splitter: None,
drag_split_origin: None,
sftp_panel_minimized: false,
sidebar_collapsed: config.sidebar_collapsed(),
collapsed_saved_scroll_handle: gpui::ScrollHandle::new(),
prev_monitoring_size: None,
status: "ready".into(),
config,
system_sampler,
recording_action: None,
active_dialog: None,
keybind_error: None,
keybinds_suspended: false,
system,
cpu_history: Vec::with_capacity(20),
net_rx_history: Vec::with_capacity(20),
@@ -434,7 +497,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,
@@ -476,7 +539,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;
@@ -543,7 +608,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();
@@ -634,8 +700,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());
@@ -648,7 +714,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();
@@ -659,14 +726,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;
@@ -699,12 +768,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 {
@@ -716,6 +780,9 @@ impl Ashell {
state: crate::terminal::TransferState::Running,
},
);
if self.transfers.len() > 100 {
self.transfers.truncate(100);
}
transfers_changed = true;
}
BackendEvent::SftpHome { tab_id, home } => {
@@ -743,7 +810,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;
}
@@ -777,12 +849,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;
}
@@ -802,14 +880,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();
}
}
+88 -32
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,32 +239,57 @@ 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 focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let sftp_minimized = view.read(cx).config.sftp_panel_minimized();
if sftp_minimized {
window.defer(cx, {
let view = view.clone();
move |window, cx| {
view.update(cx, |this, cx| {
this.toggle_sftp_minimized(window, cx);
});
}
});
}
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
let body_panels_clone = view.read(cx).body_panels.clone();
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
if view_clone.read(cx).is_layout_reset {
tracing::info!("[ui] layout was reset, skipping save layout state.");
return true;
}
tracing::info!("[ui] main application window closed, saving layout state...");
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
let current_bounds = window.window_bounds();
let saved_bounds = match current_bounds {
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)
@@ -251,13 +297,24 @@ pub(crate) fn open_main_window(cx: &mut App) {
.iter()
.map(|s| s.into())
.collect();
let body_sizes: Vec<f32> = body_panels_clone
let mut body_sizes: Vec<f32> = body_panels_clone
.read(cx)
.sizes()
.iter()
.map(|s| s.into())
.collect();
if view_clone.read(cx).sftp_panel_minimized {
if let Some(prev) = view_clone.read(cx).prev_monitoring_size {
if body_sizes.len() > 1 {
body_sizes[1] = prev.into();
}
}
}
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
config.set_sidebar_collapsed(view_clone.read(cx).sidebar_collapsed);
config.set_sftp_panel_minimized(view_clone.read(cx).sftp_panel_minimized);
let _ = config.save();
true
});
@@ -266,4 +323,3 @@ pub(crate) fn open_main_window(cx: &mut App) {
})
.expect("failed to open window");
}
+26 -102
View File
@@ -1,11 +1,6 @@
use anyhow::{Context as _, Result};
use gpui::{Anchor, App, Context, IntoElement, SharedString, Window, px};
use gpui_component::{
ActiveTheme as _, IconName, Sizable as _, Theme, ThemeMode, ThemeRegistry,
button::{Button, ButtonVariants as _},
menu::{DropdownMenu as _, PopupMenuItem},
};
use rust_i18n::t;
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use crate::Ashell;
@@ -17,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")?;
@@ -42,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);
@@ -51,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();
@@ -87,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" {
@@ -146,93 +159,4 @@ impl Ashell {
tracing::warn!("failed to save theme preferences: {err:#}");
}
}
pub(crate) fn theme_dropdown(&self, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let themes = ThemeRegistry::global(cx)
.sorted_themes()
.into_iter()
.cloned()
.collect::<Vec<_>>();
let light_themes = themes
.iter()
.filter(|theme| !theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let dark_themes = themes
.iter()
.filter(|theme| theme.mode.is_dark())
.map(|theme| theme.name.clone())
.collect::<Vec<_>>();
let follow_system = self.follow_system_theme;
let is_dark_mode = cx.theme().mode.is_dark();
let light_theme_name = self.light_theme_name.clone();
let dark_theme_name = self.dark_theme_name.clone();
let icon = if follow_system {
IconName::Sun
} else if is_dark_mode {
IconName::Moon
} else {
IconName::Sun
};
Button::new("theme-dropdown")
.ghost()
.small()
.icon(icon)
.dropdown_menu_with_anchor(Anchor::BottomRight, move |mut menu, window, _| {
menu = menu
.min_w(220.)
.item(
PopupMenuItem::new(t!("follow_system"))
.checked(follow_system)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.set_follow_system_theme(true, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_light_mode"))
.checked(!follow_system && !is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Light, window, cx)
})),
)
.item(
PopupMenuItem::new(t!("use_dark_mode"))
.checked(!follow_system && is_dark_mode)
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.switch_theme_mode(ThemeMode::Dark, window, cx)
})),
)
.separator()
.label(t!("light_theme").to_string());
for theme_name in light_themes.clone() {
let checked = theme_name == light_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu = menu.separator();
menu = menu.label(t!("dark_theme").to_string());
for theme_name in dark_themes.clone() {
let checked = theme_name == dark_theme_name;
menu = menu.item(
PopupMenuItem::new(theme_name.clone())
.checked(checked)
.on_click(window.listener_for(&view, move |this, _, window, cx| {
this.apply_theme(theme_name.clone(), window, cx)
})),
);
}
menu
})
}
}
+1069 -473
View File
File diff suppressed because it is too large Load Diff
+30 -18
View File
@@ -94,28 +94,40 @@ pub fn spawn_local_terminal(
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
while let Ok(command) = cmd_rx.recv() {
match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
loop {
match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(command) => match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
});
break;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
let _ = write_events.send(BackendEvent::Closed {
tab_id: write_tab.clone(),
reason: format!("local write error: {err}"),
tab_id: write_tab,
reason: format!("local shell exited: {status}"),
});
break;
return;
}
let _ = writer.flush();
}
BackendCommand::Resize { cols, rows } => {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
let _ = child.kill();
+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)
+20 -4
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,12 @@ rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSession,
OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight, SplitPaneUp,
ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::{
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
@@ -25,10 +31,11 @@ fn main() {
#[cfg(target_os = "macos")]
let app = gpui_platform::application()
.with_assets(Assets)
.with_quit_mode(gpui::QuitMode::Explicit);
.with_quit_mode(gpui::QuitMode::LastWindowClosed);
#[cfg(not(target_os = "macos"))]
let app = gpui_platform::application().with_assets(Assets);
app.on_reopen(|cx| {
if cx.windows().is_empty() {
app::startup::open_main_window(cx);
@@ -37,9 +44,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:#}");
+62 -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,22 @@ pub struct ConfigFile {
pub show_hidden_files: bool,
#[serde(default = "default_monitoring_position")]
pub monitoring_position: String,
#[serde(default)]
pub sidebar_collapsed: bool,
#[serde(default)]
pub sftp_panel_minimized: bool,
#[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 +201,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 +280,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"
@@ -370,6 +414,22 @@ impl ConfigStore {
self.cache.show_hidden_files = val;
}
pub fn sidebar_collapsed(&self) -> bool {
self.cache.sidebar_collapsed
}
pub fn set_sidebar_collapsed(&mut self, val: bool) {
self.cache.sidebar_collapsed = val;
}
pub fn sftp_panel_minimized(&self) -> bool {
self.cache.sftp_panel_minimized
}
pub fn set_sftp_panel_minimized(&mut self, val: bool) {
self.cache.sftp_panel_minimized = val;
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.cache.sessions.iter().find(|s| s.id == id)
}
+177 -73
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);
}
@@ -285,10 +298,10 @@ impl Ashell {
self.config.set_layout_state(None, None, None);
let _ = self.config.save();
self.is_layout_reset = true;
self.workspace_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.body_panels = cx.new(|_| gpui_component::resizable::ResizableState::default());
self.status = t!("reset_layout_success").into();
cx.notify();
}
@@ -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();
}
}
}
+337
View File
@@ -0,0 +1,337 @@
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
pub fn is_custom_block_supported(c: char) -> bool {
match c as u32 {
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534
| 0x253C => true, // Light lines
0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B
| 0x254B => true, // Heavy lines
0xE0B0..=0xE0B6 => true, // Powerline
_ => false,
}
}
pub fn paint_custom_block(
window: &mut Window,
c: char,
raw_x: f32,
raw_y: f32,
raw_w: f32,
raw_h: f32,
color: Hsla,
) -> bool {
let scale = window.scale_factor();
let x = px((raw_x * scale).round() / scale);
let y = px((raw_y * scale).round() / scale);
let w = px(((raw_x + raw_w) * scale).round() / scale) - x;
let h = px(((raw_y + raw_h) * scale).round() / scale) - y;
let mut painted = false;
// Helper to paint a quad snapping its true physical boundaries to integer physical pixels
let mut paint_quad = |qx: Pixels, qy: Pixels, qw: Pixels, qh: Pixels| {
let l = px((qx.as_f32() * scale).round() / scale);
let t = px((qy.as_f32() * scale).round() / scale);
let r = px(((qx + qw).as_f32() * scale).round() / scale);
let b = px(((qy + qh).as_f32() * scale).round() / scale);
window.paint_quad(fill(Bounds::new(point(l, t), size(r - l, b - t)), color));
painted = true;
};
let px_1 = px(1.0);
let px_2 = px(2.0);
// Box drawing thickness
let light = (w * 0.1).max(px_1);
let heavy = (w * 0.2).max(px_2);
let cx = x + w / 2.0;
let cy = y + h / 2.0;
match c as u32 {
// --- Block Elements (U+2580..=U+259F) ---
0x2580 => paint_quad(x, y, w, h / 2.0),
0x2581 => paint_quad(x, y + h * 7.0 / 8.0, w, h / 8.0),
0x2582 => paint_quad(x, y + h * 3.0 / 4.0, w, h / 4.0),
0x2583 => paint_quad(x, y + h * 5.0 / 8.0, w, h * 3.0 / 8.0),
0x2584 => paint_quad(x, y + h / 2.0, w, h / 2.0),
0x2585 => paint_quad(x, y + h * 3.0 / 8.0, w, h * 5.0 / 8.0),
0x2586 => paint_quad(x, y + h / 4.0, w, h * 3.0 / 4.0),
0x2587 => paint_quad(x, y + h / 8.0, w, h * 7.0 / 8.0),
0x2588 => paint_quad(x, y, w, h),
0x2589 => paint_quad(x, y, w * 7.0 / 8.0, h),
0x258A => paint_quad(x, y, w * 3.0 / 4.0, h),
0x258B => paint_quad(x, y, w * 5.0 / 8.0, h),
0x258C => paint_quad(x, y, w / 2.0, h),
0x258D => paint_quad(x, y, w * 3.0 / 8.0, h),
0x258E => paint_quad(x, y, w / 4.0, h),
0x258F => paint_quad(x, y, w / 8.0, h),
0x2590 => paint_quad(x + w / 2.0, y, w / 2.0, h),
0x2594 => paint_quad(x, y, w, h / 8.0),
0x2595 => paint_quad(x + w * 7.0 / 8.0, y, w / 8.0, h),
0x2596 => paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0),
0x2597 => paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0),
0x2598 => paint_quad(x, y, w / 2.0, h / 2.0),
0x2599 => {
paint_quad(x, y, w / 2.0, h);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259A => {
paint_quad(x, y, w / 2.0, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259B => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259C => {
paint_quad(x, y, w, h / 2.0);
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259D => paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0),
0x259E => {
paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
0x259F => {
paint_quad(x + w / 2.0, y, w / 2.0, h);
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
}
// --- Basic Box Drawing (U+2500..=U+257F) ---
// Light lines
0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal
0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical
0x250C => {
// Down + Right
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2510 => {
// Down + Left
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x2514 => {
// Up + Right
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2518 => {
// Up + Left
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x251C => {
// Vertical + Right
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
w / 2.0 + light / 2.0,
light,
);
}
0x2524 => {
// Vertical + Left
paint_quad(cx - light / 2.0, y, light, h);
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
}
0x252C => {
// Horizontal + Down
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(
cx - light / 2.0,
cy - light / 2.0,
light,
h / 2.0 + light / 2.0,
);
}
0x2534 => {
// Horizontal + Up
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
}
0x253C => {
// Vertical + Horizontal (Cross)
paint_quad(x, cy - light / 2.0, w, light);
paint_quad(cx - light / 2.0, y, light, h);
}
// Heavy lines
0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal
0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical
0x250F => {
// Heavy Down + Right
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x2513 => {
// Heavy Down + Left
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2517 => {
// Heavy Up + Right
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x251B => {
// Heavy Up + Left
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2523 => {
// Heavy Vertical + Right
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
w / 2.0 + heavy / 2.0,
heavy,
);
}
0x252B => {
// Heavy Vertical + Left
paint_quad(cx - heavy / 2.0, y, heavy, h);
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
}
0x2533 => {
// Heavy Horizontal + Down
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(
cx - heavy / 2.0,
cy - heavy / 2.0,
heavy,
h / 2.0 + heavy / 2.0,
);
}
0x253B => {
// Heavy Horizontal + Up
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
}
0x254B => {
// Heavy Vertical + Horizontal (Cross)
paint_quad(x, cy - heavy / 2.0, w, heavy);
paint_quad(cx - heavy / 2.0, y, heavy, h);
}
// --- Powerline ---
0xE0B0 => {
// Rightward Solid Arrow
let mut path = Path::new(point(x, y));
path.line_to(point(x + w, y + h / 2.0));
path.line_to(point(x, y + h));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B2 => {
// Leftward Solid Arrow
let mut path = Path::new(point(x + w, y));
path.line_to(point(x, y + h / 2.0));
path.line_to(point(x + w, y + h));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
0xE0B1 => {
// Rightward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x, y));
p.line_to(point(x + w, y + h / 2.0));
p.line_to(point(x + w - t, y + h / 2.0));
p.line_to(point(x, y + t));
p.line_to(point(x, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x, y + h));
p2.line_to(point(x + w, y + h / 2.0));
p2.line_to(point(x + w - t, y + h / 2.0));
p2.line_to(point(x, y + h - t));
p2.line_to(point(x, y + h));
window.paint_path(p2, color);
painted = true;
}
0xE0B3 => {
// Leftward Line Arrow
let t = px(1.0);
let mut p = Path::new(point(x + w, y));
p.line_to(point(x, y + h / 2.0));
p.line_to(point(x + t, y + h / 2.0));
p.line_to(point(x + w, y + t));
p.line_to(point(x + w, y));
window.paint_path(p, color);
let mut p2 = Path::new(point(x + w, y + h));
p2.line_to(point(x, y + h / 2.0));
p2.line_to(point(x + t, y + h / 2.0));
p2.line_to(point(x + w, y + h - t));
p2.line_to(point(x + w, y + h));
window.paint_path(p2, color);
painted = true;
}
// Half arches (Powerline rounded)
0xE0B4 => {
// Rightward Solid Semicircle
let mut path = Path::new(point(x, y));
path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0));
path.line_to(point(x, y));
window.paint_path(path, color);
painted = true;
}
0xE0B6 => {
// Leftward Solid Semicircle
let mut path = Path::new(point(x + w, y));
path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0));
path.line_to(point(x + w, y));
window.paint_path(path, color);
painted = true;
}
_ => {}
}
painted
}
+68 -7
View File
@@ -11,6 +11,7 @@ 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};
#[derive(Clone, Copy)]
@@ -138,9 +139,19 @@ pub struct PrepaintState {
metrics: TerminalMetrics,
rects: Vec<LayoutRect>,
runs: Vec<BatchedTextRun>,
custom_blocks: Vec<LayoutCustomBlock>,
cursor: Option<CursorLayout>,
}
#[derive(Clone)]
struct LayoutCustomBlock {
c: char,
row: i32,
col: i32,
cells: usize,
color: Hsla,
}
struct TerminalInputHandler {
view: Entity<Ashell>,
element_bounds: Bounds<Pixels>,
@@ -219,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(
@@ -331,9 +345,13 @@ impl TerminalElement {
}
}
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
fn layout_grid(
&self,
cx: &App,
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
let mut rects = Vec::new();
let mut runs = Vec::new();
let mut custom_blocks = Vec::new();
let mut current_run: Option<BatchedTextRun> = None;
for render_cell in &self.snapshot.cells {
@@ -352,7 +370,11 @@ impl TerminalElement {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: 1,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
@@ -371,6 +393,28 @@ impl TerminalElement {
}
let style = self.cell_run_style(cell, cx);
// Box Drawing & Block Elements interception
let is_custom_block = is_custom_block_supported(cell.c);
if is_custom_block {
if let Some(run) = current_run.take() {
runs.push(run);
}
custom_blocks.push(LayoutCustomBlock {
c: cell.c,
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
continue;
}
if let Some(run) = current_run.as_mut() {
if run.can_append(&style, render_cell.row, render_cell.col) {
run.append(cell.c, cell.zerowidth());
@@ -402,7 +446,7 @@ impl TerminalElement {
runs.push(run);
}
(merge_rects(rects), runs)
(merge_rects(rects), runs, custom_blocks)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
@@ -458,7 +502,7 @@ impl Element for TerminalElement {
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs) = self.layout_grid(cx);
let (rects, runs, custom_blocks) = self.layout_grid(cx);
PrepaintState {
bounds,
metrics: TerminalMetrics {
@@ -467,6 +511,7 @@ impl Element for TerminalElement {
},
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
}
}
@@ -489,6 +534,22 @@ impl Element for TerminalElement {
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
}
for block in &prepaint.custom_blocks {
let x = prepaint.bounds.origin.x.as_f32()
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = prepaint.bounds.origin.y.as_f32()
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
block.c,
x,
y,
prepaint.metrics.cell_width.as_f32() * block.cells as f32,
prepaint.metrics.line_height.as_f32(),
block.color,
);
}
window.handle_input(
&self.focus_handle,
TerminalInputHandler {
+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 -2
View File
@@ -1,3 +1,4 @@
pub mod custom_blocks;
pub mod element;
pub mod input;
@@ -243,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 });
}
@@ -673,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,