Compare commits

..
12 Commits
Author SHA1 Message Date
TomZz 2276eef191 feat: complete internationalization and ui adjustments
- Add full i18n support for UI components including settings and sidebar
- Add connection progress tracking strings (Connecting, Failed, etc.)
- Fix 'No session' display issue when no SSH/Local tab is active
- Align SFTP 'show hidden files' checkbox text size with adjacent buttons
- Add border to language settings dropdown for better visibility
2026-06-09 19:36:18 +08:00
TomZz bb39626173 feat: add sftp multi-selection, batch download, and drag-to-upload support 2026-06-09 18:44:25 +08:00
TomZz 2cb8ab50aa style: optimize initial window size and positioning on scaled displays 2026-06-09 18:16:11 +08:00
TomZz 785670c428 feat: sync sftp directory with terminal directory 2026-06-09 15:21:36 +08:00
TomZz d33babe552 docs: add Mac installation guide regarding ad-hoc signing 2026-06-09 13:18:51 +08:00
TomZz 985f88fb1a ci: use macos-14 runner for Intel macOS to avoid queue delays 2026-06-09 13:07:40 +08:00
TomZz 8deafbfe4e ci: add GitHub Actions release workflow 2026-06-09 12:43:17 +08:00
TomZz efc57560d8 docs: add Windows platform to download section 2026-06-09 05:50:07 +08:00
TomZz a7428bd47c docs: make Chinese the default README and add download link 2026-06-09 05:48:23 +08:00
TomZz a44bbe1580 docs: add preview image to README 2026-06-09 04:05:53 +08:00
TomZz 56030a0e77 feat: change system theme sync interval to 1s 2026-06-09 03:47:24 +08:00
TomZz 56b41d625a feat: hide console window on windows and auto-sync system theme 2026-06-09 03:38:52 +08:00
13 changed files with 897 additions and 175 deletions
+156
View File
@@ -0,0 +1,156 @@
name: Release
# Build native binaries for Windows / Linux / macOS.
# - Push a tag like `v0.2.3` -> builds all platforms and attaches the archives
# to a GitHub Release.
# - Run manually (workflow_dispatch) -> builds all platforms and uploads them as
# downloadable workflow artifacts (no release created).
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write # needed to create / update the GitHub Release
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
bin: ashell.exe
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
bin: ashell
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
bin: ashell
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
bin: ashell
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)
run: cargo build --release --target ${{ matrix.target }}
- name: Package
shell: bash
run: |
set -eu
VERSION="${GITHUB_REF_NAME:-dev}"
STAGE="ashell-${VERSION}-${{ matrix.name }}"
if [ "${{ runner.os }}" = "macOS" ]; then
# ── Build a proper .app bundle ────────────────────────────────────
# Note: We build the bundle manually here rather than running
# scripts/package-macos-app.sh, because that script runs `cargo build`
# without respecting the `--target` flag from the matrix.
APP="ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
VERSION_NUM="${VERSION#v}"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>ashell</string>
<key>CFBundleIdentifier</key>
<string>dev.ashell.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ashell</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION_NUM}</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
# Ad-hoc code-sign the assembled bundle.
codesign --force --deep --sign - "$APP"
codesign --verify --verbose "$APP"
# Package with ditto (Apple's tool) to preserve signature/metadata
ditto -c -k --keepParent "$APP" "${STAGE}.zip"
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
elif [ "${{ runner.os }}" = "Windows" ]; then
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
7z a "${STAGE}.zip" "$STAGE" >/dev/null
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
else
# Linux
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
tar -czf "${STAGE}.tar.gz" "$STAGE"
echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV"
fi
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
path: ${{ env.ASSET }}
- name: Attach to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: ${{ env.ASSET }}
fail_on_unmatched_files: true
Generated
+75 -19
View File
@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "accesskit"
@@ -302,9 +302,11 @@ dependencies = [
"russh",
"russh-keys",
"russh-sftp",
"rust-i18n 3.1.5",
"serde",
"serde_json",
"ssh-key",
"sys-locale",
"sysinfo 0.33.1",
"tar",
"thiserror 1.0.69",
@@ -753,7 +755,7 @@ dependencies = [
"bitflags 2.12.1",
"cexpr",
"clang-sys",
"itertools 0.11.0",
"itertools 0.13.0",
"log",
"prettyplease",
"proc-macro2",
@@ -1682,7 +1684,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1942,7 +1944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2563,7 +2565,7 @@ dependencies = [
"log",
"presser",
"thiserror 2.0.18",
"windows 0.58.0",
"windows 0.62.2",
]
[[package]]
@@ -2697,7 +2699,7 @@ dependencies = [
"paste",
"regex",
"ropey",
"rust-i18n",
"rust-i18n 4.1.0",
"schemars",
"serde",
"serde_json",
@@ -3258,7 +3260,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.58.0",
"windows-core 0.62.2",
]
[[package]]
@@ -4122,7 +4124,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4308,7 +4310,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5984,6 +5986,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rust-i18n"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332"
dependencies = [
"globwalk",
"once_cell",
"regex",
"rust-i18n-macro 3.1.5",
"rust-i18n-support 3.1.5",
"smallvec",
]
[[package]]
name = "rust-i18n"
version = "4.1.0"
@@ -5992,11 +6008,28 @@ checksum = "55691a65892c33ee2de49c15ea5600c6f4a70e8eeb8e6c3cd96d2a231d230c40"
dependencies = [
"globwalk",
"regex",
"rust-i18n-macro",
"rust-i18n-support",
"rust-i18n-macro 4.1.0",
"rust-i18n-support 4.1.0",
"smallvec",
]
[[package]]
name = "rust-i18n-macro"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965"
dependencies = [
"glob",
"once_cell",
"proc-macro2",
"quote",
"rust-i18n-support 3.1.5",
"serde",
"serde_json",
"serde_yaml",
"syn",
]
[[package]]
name = "rust-i18n-macro"
version = "4.1.0"
@@ -6006,13 +6039,36 @@ dependencies = [
"glob",
"proc-macro2",
"quote",
"rust-i18n-support",
"rust-i18n-support 4.1.0",
"serde",
"serde_json",
"serde_yaml",
"syn",
]
[[package]]
name = "rust-i18n-support"
version = "3.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19"
dependencies = [
"arc-swap",
"base62",
"globwalk",
"itertools 0.11.0",
"lazy_static",
"normpath",
"once_cell",
"proc-macro2",
"regex",
"serde",
"serde_json",
"serde_yaml",
"siphasher",
"toml 0.8.23",
"triomphe",
]
[[package]]
name = "rust-i18n-support"
version = "4.1.0"
@@ -6069,7 +6125,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6082,7 +6138,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6737,7 +6793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6845,7 +6901,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7205,7 +7261,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7682,7 +7738,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -8442,7 +8498,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+2
View File
@@ -24,6 +24,8 @@ rfd = "0.15"
russh = "0.49"
russh-keys = "0.49"
russh-sftp = "2"
rust-i18n = "3"
sys-locale = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ssh-key = "0.6"
+56
View File
@@ -0,0 +1,56 @@
[中文](README.md) | [English](README.en.md)
# ashell
![Preview](preview.png)
`ashell` is a modern, GPUI Component-based desktop terminal client written in Rust.
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.
## Download
You can download the latest pre-compiled releases for macOS and Windows from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
## Mac Installation Guide
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run the following command:
```bash
sudo xattr -cr /Applications/ashell.app
```
## Features
The current version provides a fully-featured GPUI-native workspace:
- **Local & Remote Sessions:** Open local terminal tabs or connect to remote servers via SSH.
- **Advanced SSH Authentication:** Supports both password-based and key-based (file path or inline) SSH connections.
- **Session Management:** Easily save, reopen, edit, and remove your SSH sessions.
- **SFTP Integration:** Built-in SFTP file manager to browse, upload, download, and manage remote files.
- **Robust Terminal Emulator:** Parses terminal output with `alacritty_terminal`, supporting rich ANSI color spans, fast rendering, and complete keyboard input forwarding.
- **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.
## Run
To run the application locally:
```bash
cargo run --release
```
## Package macOS App
```bash
./scripts/package-macos-app.sh
open target/release/ashell.app
```
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
## License
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
+34 -19
View File
@@ -1,41 +1,56 @@
[English](README.md) | [中文](README.zh-CN.md)
[中文](README.md) | [English](README.en.md)
# ashell
`ashell` is a modern, GPUI Component-based desktop terminal client written in Rust.
![Preview](preview.png)
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.
`ashell` 是一款现代化的、基于 GPUI Component 构建的 Rust 桌面终端客户端。
## Features
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
The current version provides a fully-featured GPUI-native workspace:
## 下载
- **Local & Remote Sessions:** Open local terminal tabs or connect to remote servers via SSH.
- **Advanced SSH Authentication:** Supports both password-based and key-based (file path or inline) SSH connections.
- **Session Management:** Easily save, reopen, edit, and remove your SSH sessions.
- **SFTP Integration:** Built-in SFTP file manager to browse, upload, download, and manage remote files.
- **Robust Terminal Emulator:** Parses terminal output with `alacritty_terminal`, supporting rich ANSI color spans, fast rendering, and complete keyboard input forwarding.
- **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.
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS 和 Windows 版本的最新预编译程序。
## Run
## Mac 安装指南
To run the application locally:
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
```bash
sudo xattr -cr /Applications/ashell.app
```
## 功能特性
当前版本提供了一个功能完备的 GPUI 原生工作区:
- **本地与远程会话**:支持打开本地终端标签页或通过 SSH 连接到远程服务器。
- **高级 SSH 认证**:支持基于密码和基于密钥(文件路径或内联内容)的 SSH 连接方式。
- **会话管理**:可以轻松地保存、重新打开、编辑和删除您的 SSH 会话。
- **SFTP 集成**:内置 SFTP 文件管理器,可以浏览、上传、下载以及管理远程文件。
- **强大的终端模拟器**:使用 `alacritty_terminal` 解析终端输出,支持丰富的 ANSI 颜色代码、极速渲染和完整的键盘输入转发。
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
## 运行
在本地运行该应用:
```bash
cargo run --release
```
## Package macOS App
## 打包 macOS 应用
```bash
./scripts/package-macos-app.sh
open target/release/ashell.app
```
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## License
## 协议
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
-41
View File
@@ -1,41 +0,0 @@
[English](README.md) | [中文](README.zh-CN.md)
# ashell
`ashell` 是一款现代化的、基于 GPUI Component 构建的 Rust 桌面终端客户端。
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
## 功能特性
当前版本提供了一个功能完备的 GPUI 原生工作区:
- **本地与远程会话**:支持打开本地终端标签页或通过 SSH 连接到远程服务器。
- **高级 SSH 认证**:支持基于密码和基于密钥(文件路径或内联内容)的 SSH 连接方式。
- **会话管理**:可以轻松地保存、重新打开、编辑和删除您的 SSH 会话。
- **SFTP 集成**:内置 SFTP 文件管理器,可以浏览、上传、下载以及管理远程文件。
- **强大的终端模拟器**:使用 `alacritty_terminal` 解析终端输出,支持丰富的 ANSI 颜色代码、极速渲染和完整的键盘输入转发。
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
## 运行
在本地运行该应用:
```bash
cargo run --release
```
## 打包 macOS 应用
```bash
./scripts/package-macos-app.sh
open target/release/ashell.app
```
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
## 协议
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
+60
View File
@@ -0,0 +1,60 @@
remote_files: "REMOTE FILES"
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
sftp_connecting: "sftp connecting..."
refresh: "Refresh"
upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
download: "Download"
download_count: "Download (%{count})"
name: "NAME"
size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
edit: "Edit"
delete: "Delete"
host: "Host"
port: "Port"
user: "User"
password: "Password"
key: "Key"
private_key_path: "Private Key Path"
private_key_data: "Private Key Data"
save_and_connect: "Save & Connect"
save: "Save"
cancel: "Cancel"
host_and_user_required: "Host and user are required"
language: "Language"
follow_system: "Follow System"
light_theme: "Light Theme"
dark_theme: "Dark Theme"
use_light_mode: "Use Light Mode"
use_dark_mode: "Use Dark Mode"
english: "English"
chinese: "简体中文"
idle_no_session: "idle / No session"
system: "SYSTEM"
cpu: "CPU"
mem: "MEM"
swap: "SWAP"
net: "NET"
disk: "DISK"
global_load: "global load"
live: "Live"
idle: "idle"
saved: "SAVED"
add_ssh: "+ ssh"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
terminal_font_size: "Terminal Font Size"
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
local_host: "local host"
local_terminal_opened: "local terminal opened"
connecting: "Connecting"
starting_connection: "starting connection..."
connection_failed: "Connection Failed"
+60
View File
@@ -0,0 +1,60 @@
remote_files: "远程文件"
open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。"
sftp_connecting: "正在连接 SFTP..."
refresh: "刷新"
upload_file: "上传文件"
upload_folder: "上传文件夹"
hidden: "显示隐藏文件"
download: "下载"
download_count: "下载 (%{count})"
name: "名称"
size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
connect: "连接"
edit: "编辑"
delete: "删除"
host: "主机 (Host)"
port: "端口 (Port)"
user: "用户 (User)"
password: "密码 (Password)"
key: "密钥 (Key)"
private_key_path: "私钥路径"
private_key_data: "私钥内容"
save_and_connect: "保存并连接"
save: "保存"
cancel: "取消"
host_and_user_required: "主机名和用户名不能为空"
language: "显示语言"
follow_system: "跟随系统"
light_theme: "浅色主题"
dark_theme: "深色主题"
use_light_mode: "使用浅色模式"
use_dark_mode: "使用深色模式"
english: "English"
chinese: "简体中文"
idle_no_session: "空闲 / 未连接会话"
system: "系统"
cpu: "CPU"
mem: "内存"
swap: "虚拟内存"
net: "网络"
disk: "磁盘"
global_load: "全局负载"
live: "实时"
idle: "空闲"
saved: "已保存会话"
add_ssh: "+ SSH"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
terminal_font_size: "终端字体大小"
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+18
View File
@@ -78,12 +78,18 @@ pub struct ConfigFile {
pub light_theme_name: String,
#[serde(default)]
pub dark_theme_name: String,
#[serde(default = "default_locale")]
pub locale: String,
#[serde(default = "default_terminal_font_size")]
pub terminal_font_size: f32,
#[serde(default)]
pub sessions: Vec<Session>,
}
fn default_locale() -> String {
"system".to_string()
}
fn default_terminal_font_size() -> f32 {
13.0
}
@@ -144,6 +150,18 @@ impl ConfigStore {
&self.cache.dark_theme_name
}
pub fn locale(&self) -> &str {
if self.cache.locale.is_empty() {
"system"
} else {
&self.cache.locale
}
}
pub fn set_locale(&mut self, locale: &str) {
self.cache.locale = locale.to_string();
}
pub fn terminal_font_size(&self) -> f32 {
if self.cache.terminal_font_size <= 0.0 {
default_terminal_font_size()
+405 -85
View File
@@ -1,5 +1,6 @@
#![windows_subsystem = "windows"]
use std::{
borrow::Cow,
cell::{Cell, RefCell},
collections::HashMap,
ops::Range,
@@ -13,14 +14,16 @@ use alacritty_terminal::index::Side;
use alacritty_terminal::selection::SelectionType;
use anyhow::{Context as _, Result};
use gpui::{
div, point, prelude::FluentBuilder as _, px, size, uniform_list, Anchor, App, AppContext as _,
Bounds, ClipboardItem, Context, Entity, FocusHandle, Focusable as _, FontWeight, Hsla,
InteractiveElement as _, IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement as _, PathPromptOptions, Pixels, Point, QuitMode,
Render, ScrollDelta, ScrollWheelEvent, SharedString, Size, Styled as _,
UniformListScrollHandle, Window, WindowOptions,
Anchor, App, AppContext as _, Bounds, ClipboardItem, Context, ElementId, Entity, FocusHandle,
Focusable as _, FontWeight, Hsla, InteractiveElement as _, IntoElement, KeyBinding,
KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
PathPromptOptions, Pixels, Point, QuitMode, Render, ScrollDelta, ScrollWheelEvent,
SharedString, Size, Styled as _, UniformListScrollHandle, Window, WindowOptions, div, point,
prelude::FluentBuilder as _, px, size, uniform_list,
};
use gpui_component::{
ActiveTheme as _, Disableable, ElementExt, IconName, Root, Sizable as _, Theme, ThemeMode,
ThemeRegistry, WindowExt as _,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
dialog::Dialog,
@@ -28,11 +31,10 @@ use gpui_component::{
input::{Input, InputEvent, InputState},
menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenuItem},
progress::Progress,
resizable::{h_resizable, resizable_panel, v_resizable, ResizableState},
resizable::{ResizableState, h_resizable, resizable_panel, v_resizable},
scroll::{ScrollableElement as _, Scrollbar, ScrollbarHandle, ScrollbarShow},
tab::{Tab, TabBar},
v_flex, ActiveTheme as _, ElementExt, IconName, Root, Sizable as _, Theme, ThemeMode,
ThemeRegistry, WindowExt as _,
v_flex,
};
use gpui_component_assets::Assets;
use tokio::runtime::Runtime;
@@ -47,11 +49,13 @@ mod terminal;
mod terminal_element;
use config::{AuthMethod, ConfigStore, Session};
use sftp::{format_mtime, RemoteEntry, SftpHandle};
use system::{format_bytes, DiskSample, SystemSampler, SystemSnapshot};
use terminal::{encode_key, BackendCommand, BackendEvent, TabKind, TerminalTab};
use sftp::{RemoteEntry, SftpHandle, format_mtime};
use system::{DiskSample, SystemSampler, SystemSnapshot, format_bytes};
use terminal::{BackendCommand, BackendEvent, TabKind, TerminalTab, encode_key};
use terminal_element::TerminalElement;
rust_i18n::i18n!("locales", fallback = "en");
const DEFAULT_COLS: u16 = 100;
const DEFAULT_ROWS: u16 = 30;
const APP_FONT_FAMILY: &str = "Maple Mono NF CN";
@@ -166,6 +170,7 @@ struct Ashell {
system_sampler: SystemSampler,
system: SystemSnapshot,
last_system_sample: Instant,
last_theme_sync: Instant,
remote_sample_in_flight: bool,
runtime: Runtime,
events_rx: mpsc::Receiver<BackendEvent>,
@@ -197,14 +202,15 @@ struct SftpContextMenuState {
impl Ashell {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let host_input = cx.new(|cx| InputState::new(window, cx).placeholder("host"));
let host_input =
cx.new(|cx| InputState::new(window, cx).placeholder(rust_i18n::t!("host")));
let session_name_input =
cx.new(|cx| InputState::new(window, cx).placeholder("name (optional)"));
let port_input = cx.new(|cx| InputState::new(window, cx).default_value("22"));
let user_input = cx.new(|cx| InputState::new(window, cx).default_value("root"));
let password_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("password")
.placeholder(rust_i18n::t!("password"))
.masked(true)
});
let key_path_input =
@@ -255,6 +261,19 @@ impl Ashell {
} else {
config.dark_theme_name().into()
};
let configured_locale = config.locale();
let mut active_locale = configured_locale.to_string();
if active_locale == "system" {
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
if active_locale.starts_with("zh") {
active_locale = "zh-CN".to_string();
} else {
active_locale = "en".to_string();
}
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
let mut this = Self {
focus_handle: cx.focus_handle(),
selector_focus_handle: cx.focus_handle(),
@@ -293,6 +312,7 @@ impl Ashell {
system_sampler,
system,
last_system_sample: Instant::now(),
last_theme_sync: Instant::now(),
remote_sample_in_flight: false,
runtime: Runtime::new().expect("create tokio runtime"),
events_rx,
@@ -331,19 +351,22 @@ impl Ashell {
}
fn start_event_pump(&self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
if this
.update(cx, |this, cx| {
this.drain_backend_events();
this.sample_system_if_due();
cx.notify();
})
.is_err()
{
break;
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_millis(16))
.await;
if this
.update(cx, |this, cx| {
this.drain_backend_events();
this.sample_system_if_due();
this.sync_theme_if_due(cx);
cx.notify();
})
.is_err()
{
break;
}
}
})
.detach();
@@ -446,12 +469,32 @@ impl Ashell {
progress.lines.push(reason.clone().into());
let _ = session_label;
let _ = tab_title;
progress.title = "Connection Failed".into();
progress.title = rust_i18n::t!("connection_failed").into();
progress.failed = true;
}
}
self.status = reason.into();
}
BackendEvent::TerminalTitleChanged { tab_id, title } => {
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) {
if let Some(sftp) = &tab.sftp {
let potential_path = if let Some((_, p)) = title.rsplit_once(':') {
p.trim()
} else {
title.trim()
};
if potential_path.starts_with('/') || potential_path.starts_with('~') {
let path_to_sync = potential_path.to_string();
if path_to_sync != sftp.current_path {
if let Some(handle) = self.sftp_handles.get(&tab_id) {
handle.list_dir(path_to_sync);
}
}
}
}
}
}
}
}
}
@@ -462,6 +505,13 @@ impl Ashell {
}
}
fn sync_theme_if_due(&mut self, cx: &mut Context<Self>) {
if self.follow_system_theme && self.last_theme_sync.elapsed() >= Duration::from_secs(1) {
Theme::sync_system_appearance(None, cx);
self.last_theme_sync = Instant::now();
}
}
fn request_active_system_snapshot(&mut self) {
if let Some((tab_id, session)) = self.active_ssh_session() {
if self.remote_sample_in_flight {
@@ -500,7 +550,8 @@ impl Ashell {
) {
Ok(backend) => {
let title = if cfg!(windows) { "PowerShell" } else { "Local" }.to_string();
let mut tab = TerminalTab::new_local(id.clone(), title, backend);
let mut tab =
TerminalTab::new_local(id.clone(), title, backend, self.events_tx.clone());
tab.resize(DEFAULT_COLS, DEFAULT_ROWS);
self.tabs.push(tab);
self.active_tab = Some(id);
@@ -529,7 +580,7 @@ impl Ashell {
let key_inline = self.key_inline_input.read(cx).value().to_string();
if host.is_empty() || user.is_empty() {
self.status = "host and user are required".into();
self.status = rust_i18n::t!("host_and_user_required").into();
cx.notify();
return;
}
@@ -670,7 +721,7 @@ impl Ashell {
window.open_dialog(cx, move |dialog: Dialog, _window, _cx| {
dialog
.title("SSH Session")
.title(rust_i18n::t!("new_ssh_connection"))
.w(px(520.))
.overlay_closable(true)
.content({
@@ -701,7 +752,7 @@ impl Ashell {
.gap_2()
.child(
Button::new("ssh-auth-password")
.label("password")
.label(rust_i18n::t!("password").to_string())
.when(is_password, |button| button.primary())
.on_click(window.listener_for(
&view,
@@ -715,7 +766,7 @@ impl Ashell {
)
.child(
Button::new("ssh-auth-key")
.label("key")
.label(rust_i18n::t!("key").to_string())
.when(!is_password, |button| button.primary())
.on_click(window.listener_for(
&view,
@@ -744,13 +795,17 @@ impl Ashell {
.gap_2()
.child(
Button::new("connect-ssh-cancel")
.label("cancel")
.label(rust_i18n::t!("cancel").to_string())
.on_click(|_, window, cx| window.close_dialog(cx)),
)
.child(
Button::new("connect-ssh-confirm")
.primary()
.label(if is_editing { "save" } else { "connect" })
.label(if is_editing {
rust_i18n::t!("save")
} else {
rust_i18n::t!("connect")
})
.on_click(window.listener_for(
&view,
|this, _, window, cx| {
@@ -837,13 +892,15 @@ impl Ashell {
div()
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.child("Local Terminal"),
.child(rust_i18n::t!("local_terminal")),
)
.child(
div()
.text_size(px(11.))
.text_color(_cx.theme().muted_foreground)
.child("Open a new local shell tab"),
.child(rust_i18n::t!(
"open_local_shell_tab"
)),
),
),
)
@@ -879,13 +936,15 @@ impl Ashell {
div()
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.child("New SSH Connection"),
.child(rust_i18n::t!("new_ssh_connection")),
)
.child(
div()
.text_size(px(11.))
.text_color(_cx.theme().muted_foreground)
.child("Create or edit an SSH session"),
.child(rust_i18n::t!(
"create_or_edit_ssh_session"
)),
),
),
)
@@ -971,7 +1030,7 @@ impl Ashell {
let view = cx.entity();
window.open_dialog(cx, move |dialog: Dialog, _window, _| {
dialog
.title("Settings")
.title(rust_i18n::t!("settings").to_string())
.w(px(480.))
.content({
let view = view.clone();
@@ -983,7 +1042,7 @@ impl Ashell {
h_flex()
.items_center()
.gap_3()
.child(div().w(px(180.)).child("Terminal Font Size"))
.child(div().w(px(180.)).child(rust_i18n::t!("terminal_font_size").to_string()))
.child(
Button::new("font-size-down")
.label("-")
@@ -1005,11 +1064,63 @@ impl Ashell {
})),
),
)
.child(
h_flex()
.items_center()
.gap_3()
.child(div().w(px(180.)).child(rust_i18n::t!("language").to_string()))
.child(
Button::new("language-dropdown")
.small()
.icon(IconName::Globe)
.label({
let current_locale = view.read(cx).config.locale().to_string();
if current_locale == "en" {
rust_i18n::t!("english").to_string()
} else if current_locale == "zh-CN" {
rust_i18n::t!("chinese").to_string()
} else {
rust_i18n::t!("follow_system").to_string()
}
})
.dropdown_menu_with_anchor(Anchor::BottomRight, {
let view = view.clone();
move |mut menu, window, cx| {
let current_locale = view.read(cx).config.locale().to_string();
menu = menu
.min_w(160.)
.item(
PopupMenuItem::new(rust_i18n::t!("follow_system").to_string())
.checked(current_locale == "system")
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.set_display_language("system", window, cx)
})),
)
.separator()
.item(
PopupMenuItem::new(rust_i18n::t!("english").to_string())
.checked(current_locale == "en")
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.set_display_language("en", window, cx)
})),
)
.item(
PopupMenuItem::new(rust_i18n::t!("chinese").to_string())
.checked(current_locale == "zh-CN")
.on_click(window.listener_for(&view, |this, _, window, cx| {
this.set_display_language("zh-CN", window, cx)
})),
);
menu
}
})
)
)
.child(
div()
.text_size(px(12.))
.text_color(cx.theme().muted_foreground)
.child("Theme mode and light/dark theme families are managed from the top-bar theme button."),
.child(rust_i18n::t!("theme_management_hint")),
),
)
}
@@ -1122,8 +1233,12 @@ impl Ashell {
DEFAULT_ROWS,
self.events_tx.clone(),
);
self.tabs
.push(TerminalTab::new_ssh(id.clone(), &session, backend));
self.tabs.push(TerminalTab::new_ssh(
id.clone(),
&session,
backend,
self.events_tx.clone(),
));
let sftp_handle = sftp::spawn_sftp(
self.runtime.handle(),
id.clone(),
@@ -1135,8 +1250,8 @@ impl Ashell {
self.pending_sftp_path_sync = Some("/".into());
self.connection_progress = Some(ConnectionProgress {
tab_id: id,
title: "Connecting".into(),
lines: vec!["starting connection...".into()],
title: rust_i18n::t!("connecting").into(),
lines: vec![rust_i18n::t!("starting_connection").into()],
failed: false,
});
self.status = "ssh tab opened".into();
@@ -1489,6 +1604,26 @@ impl Ashell {
cx.notify();
}
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" {
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
if active_locale.starts_with("zh") {
active_locale = "zh-CN".to_string();
} else {
active_locale = "en".to_string();
}
}
rust_i18n::set_locale(&active_locale);
gpui_component::set_locale(&active_locale);
if let Err(err) = self.config.save() {
tracing::warn!("failed to save language preferences: {err:#}");
}
window.refresh();
cx.notify();
}
fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let light_theme = ThemeRegistry::global(cx)
.themes()
@@ -1874,7 +2009,7 @@ impl Ashell {
div()
.text_size(px(13.))
.text_color(cx.theme().muted_foreground)
.child("Open a local terminal or pick an SSH session."),
.child(rust_i18n::t!("open_local_or_ssh")),
)
.child(
h_flex()
@@ -1882,13 +2017,13 @@ impl Ashell {
.child(
Button::new("home-open-local")
.primary()
.label("Local Terminal")
.label(rust_i18n::t!("local_terminal").to_string())
.on_click(cx.listener(|this, _, _, cx| this.open_local(cx))),
)
.child(
Button::new("home-open-session")
.ghost()
.label("Open Session")
.label(rust_i18n::t!("open_session").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.show_selector_dialog(window, cx)
})),
@@ -1932,7 +2067,7 @@ impl Ashell {
.as_ref()
.and_then(|id| self.tabs.iter().find(|t| &t.id == id))
.map(|t| t.title.clone())
.unwrap_or_else(|| "No session".into())
.unwrap_or_else(|| rust_i18n::t!("idle_no_session").into())
}
fn active_ssh_session(&self) -> Option<(String, Session)> {
@@ -1993,7 +2128,7 @@ impl Ashell {
fn system_card(
&self,
label: &'static str,
label: String,
percent: f32,
detail: String,
fill: Hsla,
@@ -2016,7 +2151,7 @@ impl Ashell {
.text_size(px(11.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(fill)
.child(label),
.child(label.clone()),
)
.child(div().flex_1())
.child(
@@ -2062,14 +2197,14 @@ impl Ashell {
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().chart_4)
.child("NET"),
.child(rust_i18n::t!("net")),
)
.child(div().flex_1())
.child(
div()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.child("live"),
.child(rust_i18n::t!("live")),
),
)
.child(
@@ -2160,6 +2295,89 @@ impl Ashell {
)
}
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);
} else {
sftp.selected_entries.remove(&path);
}
cx.notify();
}
}
fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
if checked {
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
for path in paths {
sftp.selected_entries.insert(path);
}
} else {
sftp.selected_entries.clear();
}
cx.notify();
}
}
fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(sftp) = self.active_sftp() else {
return;
};
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
if selected.is_empty() {
return;
}
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select Download Folder".into()),
});
cx.spawn_in(window, async move |this, cx| {
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();
for remote in selected {
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
remote,
local_dir: local_dir.clone(),
});
}
let _ = this.update(cx, |this, cx| {
if let Some(sftp_mut) = this.active_sftp_mut() {
sftp_mut.selected_entries.clear();
}
cx.notify();
});
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
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() {
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
locals: paths,
remote_dir: sftp.current_path.clone(),
});
}
}
}
fn theme_dropdown(&self, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let themes = ThemeRegistry::global(cx)
@@ -2197,28 +2415,28 @@ impl Ashell {
menu = menu
.min_w(220.)
.item(
PopupMenuItem::new("Follow System")
PopupMenuItem::new(rust_i18n::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("Use Light Mode")
PopupMenuItem::new(rust_i18n::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("Use Dark Mode")
PopupMenuItem::new(rust_i18n::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("Light Theme");
.label(rust_i18n::t!("light_theme").to_string());
for theme_name in light_themes.clone() {
let checked = theme_name == light_theme_name;
@@ -2232,7 +2450,7 @@ impl Ashell {
}
menu = menu.separator();
menu = menu.label("Dark Theme");
menu = menu.label(rust_i18n::t!("dark_theme").to_string());
for theme_name in dark_themes.clone() {
let checked = theme_name == dark_theme_name;
@@ -2266,13 +2484,13 @@ impl Ashell {
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().primary)
.child("REMOTE FILES"),
.child(rust_i18n::t!("remote_files")),
)
.child(
div()
.text_size(px(12.))
.text_color(cx.theme().muted_foreground)
.child("Open an SSH tab to browse SFTP files."),
.child(rust_i18n::t!("open_ssh_tab_sftp")),
);
};
@@ -2284,6 +2502,11 @@ impl Ashell {
.filter(|entry| self.show_hidden_files || !entry.name.starts_with('.'))
.collect::<Vec<_>>();
let status = sftp.status.clone();
let selected_entries = sftp.selected_entries.clone();
let all_selected = !entries.is_empty()
&& entries
.iter()
.all(|e| selected_entries.contains(&e.full_path));
let parent_path = Self::sftp_parent_path(&sftp.current_path);
let view = cx.entity();
let icon_col_width = px(14.);
@@ -2295,6 +2518,16 @@ impl Ashell {
.gap_0()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.on_drop(
cx.listener(|this, paths: &gpui::ExternalPaths, window, cx| {
let paths_to_upload: Vec<String> = paths
.0
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
this.upload_sftp_files_batch(paths_to_upload, cx);
}),
)
.child(
h_flex()
.h(px(34.))
@@ -2309,7 +2542,7 @@ impl Ashell {
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().primary)
.child("REMOTE FILES"),
.child(rust_i18n::t!("remote_files")),
)
.child(div().flex_1())
.child(
@@ -2317,7 +2550,7 @@ impl Ashell {
.ghost()
.small()
.icon(IconName::ArrowRight)
.label("refresh")
.label(rust_i18n::t!("refresh").to_string())
.on_click(cx.listener(|this, _, _, cx| this.refresh_sftp(cx))),
)
.child(
@@ -2325,7 +2558,7 @@ impl Ashell {
.ghost()
.small()
.icon(IconName::Plus)
.label("upload file")
.label(rust_i18n::t!("upload_file").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.upload_sftp_files(window, cx)
})),
@@ -2335,14 +2568,30 @@ impl Ashell {
.ghost()
.small()
.icon(IconName::Folder)
.label("upload folder")
.label(rust_i18n::t!("upload_folder").to_string())
.on_click(cx.listener(|this, _, window, cx| {
this.upload_sftp_folder(window, cx)
})),
)
.child(
Button::new("sftp-download-selected")
.ghost()
.small()
.icon(IconName::ArrowDown)
.label(if selected_entries.is_empty() {
rust_i18n::t!("download").to_string()
} else {
rust_i18n::t!("download_count", count = selected_entries.len()).to_string()
})
.disabled(selected_entries.is_empty())
.on_click(cx.listener(|this, _, window, cx| {
this.download_selected_sftp_entries(window, cx);
})),
)
.child(
Checkbox::new("sftp-show-hidden")
.label("hidden")
.small()
.label(rust_i18n::t!("hidden").to_string())
.checked(self.show_hidden_files)
.tab_stop(false)
.on_click(cx.listener(|this, checked, _, cx| {
@@ -2381,6 +2630,20 @@ impl Ashell {
.border_b_1()
.border_color(cx.theme().border)
.bg(cx.theme().muted.opacity(0.8))
.child(
h_flex()
.w(px(24.))
.flex_none()
.items_center()
.justify_center()
.child(
Checkbox::new("sftp-select-all")
.checked(all_selected)
.on_click(cx.listener(move |this, checked, _, cx| {
this.toggle_all_sftp_entries(*checked, cx);
})),
),
)
.child(
h_flex()
.flex_1()
@@ -2393,7 +2656,7 @@ impl Ashell {
.flex_1()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.child("NAME"),
.child(rust_i18n::t!("name")),
),
)
.child(
@@ -2402,7 +2665,7 @@ impl Ashell {
.flex_none()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.child("SIZE"),
.child(rust_i18n::t!("size")),
)
.child(
div()
@@ -2410,7 +2673,7 @@ impl Ashell {
.flex_none()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.child("MODIFIED"),
.child(rust_i18n::t!("modified")),
)
.child(div().w(px(12.)).flex_none()),
)
@@ -2432,6 +2695,8 @@ impl Ashell {
let left_row = entry.clone();
let right_row = entry.clone();
let remote_path = entry.full_path.clone();
let is_checked =
selected_entries.contains(&entry.full_path);
let is_selected = selected_path
.as_deref()
.map(|path| path == entry.full_path)
@@ -2489,6 +2754,37 @@ impl Ashell {
}
}),
)
.child(
h_flex()
.w(px(24.))
.flex_none()
.items_center()
.justify_center()
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation()
})
.on_mouse_down(
MouseButton::Right,
|_, _, cx| cx.stop_propagation(),
)
.child(
Checkbox::new(ElementId::Name(
format!("check-{}", entry.full_path)
.into(),
))
.checked(is_checked)
.on_click(window.listener_for(&view, {
let path = entry.full_path.clone();
move |this, checked, _, cx| {
this.toggle_sftp_entry(
path.clone(),
*checked,
cx,
);
}
})),
),
)
.child(
h_flex()
.flex_1()
@@ -2581,11 +2877,6 @@ impl Ashell {
fn sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let sessions = self.config.sessions().to_vec();
let active_session_id = self.active_session_id().map(ToOwned::to_owned);
let active_kind = match self.active_kind() {
Some(TabKind::Local) => "local",
Some(TabKind::Ssh) => "ssh",
None => "idle",
};
let connection_color = self.active_connection_color(cx);
v_flex()
@@ -2623,7 +2914,17 @@ impl Ashell {
div()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
.child(format!("{} / {}", active_kind, self.active_title())),
.child({
if let Some(kind) = self.active_kind() {
let kind_str = match kind {
TabKind::Local => rust_i18n::t!("local_terminal").to_string(),
TabKind::Ssh => "ssh".to_string(),
};
format!("{} / {}", kind_str, self.active_title())
} else {
self.active_title()
}
}),
),
),
)
@@ -2638,7 +2939,7 @@ impl Ashell {
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().primary)
.child("SYSTEM"),
.child(rust_i18n::t!("system")),
)
.child(div().flex_1())
.child(
@@ -2657,21 +2958,21 @@ impl Ashell {
)
})
.child(self.system_card(
"CPU",
rust_i18n::t!("cpu").to_string(),
self.system.cpu_percent,
"global load".into(),
rust_i18n::t!("global_load").into(),
cx.theme().chart_1,
cx,
))
.child(self.system_card(
"MEM",
rust_i18n::t!("mem").to_string(),
self.system.mem_percent,
self.system.mem_detail.clone(),
cx.theme().chart_2,
cx,
))
.child(self.system_card(
"SWAP",
rust_i18n::t!("swap").to_string(),
self.system.swap_percent,
self.system.swap_detail.clone(),
cx.theme().chart_3,
@@ -2690,7 +2991,7 @@ impl Ashell {
div()
.text_size(px(11.))
.text_color(cx.theme().chart_5)
.child("DISK"),
.child(rust_i18n::t!("disk")),
)
.children({
let mut disk_elements = Vec::new();
@@ -2704,7 +3005,7 @@ impl Ashell {
.child(
Button::new("open-ssh-panel")
.primary()
.label("+ ssh")
.label(rust_i18n::t!("add_ssh").to_string())
.on_click(
cx.listener(|this, _, window, cx| this.open_new_ssh_dialog(window, cx)),
),
@@ -2717,7 +3018,7 @@ impl Ashell {
.text_size(px(12.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().primary)
.child("SAVED"),
.child(rust_i18n::t!("saved")),
)
.child(
v_flex()
@@ -3235,7 +3536,26 @@ fn sync_macos_launch_environment() {
fn sync_macos_launch_environment() {}
fn open_main_window(cx: &mut App) {
let window_options = WindowOptions::default();
let mut window_options = WindowOptions::default();
if let Some(display) = cx.displays().first().cloned() {
let display_bounds = display.bounds();
let width = display_bounds.size.width * 0.8;
let height = display_bounds.size.height * 0.9;
let x = display_bounds.origin.x + (display_bounds.size.width - width) / 2.0;
#[cfg(target_os = "macos")]
let y = display_bounds.origin.y;
#[cfg(not(target_os = "macos"))]
let y = display_bounds.origin.y + (display_bounds.size.height - height) / 2.0;
window_options.window_bounds = Some(gpui::WindowBounds::Windowed(Bounds::new(
point(x, y),
size(width, height),
)));
}
cx.open_window(window_options, |window, cx| {
window.activate_window();
window.set_window_title("ashell");
+11 -3
View File
@@ -47,7 +47,7 @@ pub struct PreviewData {
}
#[derive(Debug)]
enum SftpCommand {
pub enum SftpCommand {
ListDir(String),
Preview(String),
Download { remote: String, local_dir: String },
@@ -56,7 +56,7 @@ enum SftpCommand {
}
pub struct SftpHandle {
commands: UnboundedSender<SftpCommand>,
pub commands: UnboundedSender<SftpCommand>,
#[allow(dead_code)]
join: Option<JoinHandle<()>>,
}
@@ -144,7 +144,15 @@ async fn run_sftp(
match command {
SftpCommand::Close => break,
SftpCommand::ListDir(path) => {
if let Err(err) = emit_entries(&events, &tab_id, &sftp, &path).await {
let actual_path = if path == "~" {
home.clone()
} else if let Some(rest) = path.strip_prefix("~/") {
crate::sftp::join_remote(&home, rest)
} else {
path
};
if let Err(err) = emit_entries(&events, &tab_id, &sftp, &actual_path).await {
let _ = events.send(BackendEvent::SftpStatus {
tab_id: tab_id.clone(),
text: format!("list failed: {err:#}"),
+20 -8
View File
@@ -41,6 +41,7 @@ pub enum BackendEvent {
RemoteSystem { tab_id: String, snapshot: SystemSnapshot },
RemoteSystemUnavailable { tab_id: String, reason: String },
Closed { tab_id: String, reason: String },
TerminalTitleChanged { tab_id: String, title: String },
}
#[derive(Clone)]
@@ -118,20 +119,22 @@ pub struct SftpUiState {
pub entries: Vec<RemoteEntry>,
pub selected_path: Option<String>,
pub preview: Option<PreviewData>,
pub selected_entries: std::collections::HashSet<String>,
}
impl TerminalTab {
pub fn new_local(id: String, title: String, backend: BackendTx) -> Self {
Self::new(id, title, TabKind::Local, "local shell".into(), backend)
pub fn new_local(id: String, title: String, backend: BackendTx, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
Self::new(id, title, TabKind::Local, "local shell".into(), backend, events)
}
pub fn new_ssh(id: String, session: &Session, backend: BackendTx) -> Self {
pub fn new_ssh(id: String, session: &Session, backend: BackendTx, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
let mut tab = Self::new(
id,
session.name.clone(),
TabKind::Ssh,
format!("connecting {}@{}:{}", session.user, session.host, session.port),
backend,
events,
);
tab.session = Some(session.clone());
tab.connected = false;
@@ -141,13 +144,14 @@ impl TerminalTab {
entries: Vec::new(),
selected_path: None,
preview: None,
selected_entries: std::collections::HashSet::new(),
});
tab
}
fn new(id: String, title: String, kind: TabKind, status: String, backend: BackendTx) -> Self {
fn new(id: String, title: String, kind: TabKind, status: String, backend: BackendTx, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
Self {
id,
id: id.clone(),
title,
kind,
status,
@@ -155,7 +159,7 @@ impl TerminalTab {
session: None,
sftp: None,
processor: Processor::new(),
term: new_term(100, 30, backend.clone()),
term: new_term(100, 30, backend.clone(), id, events),
cols: 100,
rows: 30,
backend,
@@ -324,7 +328,9 @@ fn viewport_selection_from_range(
#[derive(Clone)]
struct TerminalListener {
tab_id: String,
backend: BackendTx,
events: std::sync::mpsc::Sender<BackendEvent>,
}
impl EventListener for TerminalListener {
@@ -341,19 +347,25 @@ impl EventListener for TerminalListener {
self.backend
.send(BackendCommand::Input(format(size).into_bytes()));
}
Event::Title(title) => {
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
tab_id: self.tab_id.clone(),
title,
});
}
_ => {}
}
}
}
fn new_term(cols: u16, rows: u16, backend: BackendTx) -> Term<TerminalListener> {
fn new_term(cols: u16, rows: u16, backend: BackendTx, tab_id: String, events: std::sync::mpsc::Sender<BackendEvent>) -> Term<TerminalListener> {
Term::new(
Config {
scrolling_history: 2000,
..Config::default()
},
&TerminalSize::new(cols, rows),
TerminalListener { backend },
TerminalListener { tab_id, backend, events },
)
}