mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
115
Commits
v0.2.4
...
v0.4.7-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847f0626f4 | ||
|
|
7cfe5c64d5 | ||
|
|
6d23d27fea | ||
|
|
da856ac9d7 | ||
|
|
7653c66a6f | ||
|
|
56fe508122 | ||
|
|
3b7165a4bd | ||
|
|
5ae44c9e44 | ||
|
|
73938aef8a | ||
|
|
59df2b5a9a | ||
|
|
b225b0e56f | ||
|
|
2cdd6d3ded | ||
|
|
42c408f63f | ||
|
|
f4005bf2bb | ||
|
|
d5fab2e9ca | ||
|
|
1b7f625aea | ||
|
|
b6b4d3c35e | ||
|
|
ae763eae1c | ||
|
|
a89d15d255 | ||
|
|
b5a068541d | ||
|
|
7be0c420e1 | ||
|
|
62028c7921 | ||
|
|
56a6ef98b7 | ||
|
|
caa8a58561 | ||
|
|
6150bd410b | ||
|
|
f55c3a8470 | ||
|
|
7ae44ab9a7 | ||
|
|
9c43b2dde7 | ||
|
|
e6134bc0d1 | ||
|
|
96cd85a8d3 | ||
|
|
f45c2ed60f | ||
|
|
8a8f280401 | ||
|
|
dc006f7144 | ||
|
|
daaebe4c2b | ||
|
|
2a37f45ba2 | ||
|
|
ee8c18844c | ||
|
|
c5b09628b3 | ||
|
|
b45364331d | ||
|
|
2d92f0ceee | ||
|
|
162a245a8f | ||
|
|
68bf152792 | ||
|
|
13b9433c90 | ||
|
|
445e474fe8 | ||
|
|
4c7c06f391 | ||
|
|
e4fbd65a31 | ||
|
|
bd376935f4 | ||
|
|
16761c1bf5 | ||
|
|
c3201e7c30 | ||
|
|
2d5062ed75 | ||
|
|
d7d6faeeb1 | ||
|
|
19a4274473 | ||
|
|
4bb3bee6c8 | ||
|
|
8ab4b43825 | ||
|
|
bbf843dde6 | ||
|
|
bc6af2e213 | ||
|
|
d224506fd5 | ||
|
|
d175135529 | ||
|
|
c769b982b4 | ||
|
|
ee68835972 | ||
|
|
8a535fad5a | ||
|
|
90c07e16e1 | ||
|
|
33a3d624b7 | ||
|
|
78f8626217 | ||
|
|
743a5f9fe7 | ||
|
|
99a954b0b5 | ||
|
|
0bd04a98f0 | ||
|
|
df44d17ade | ||
|
|
0e4c4cdc69 | ||
|
|
227966319c | ||
|
|
205ceba20c | ||
|
|
0ccbf9573b | ||
|
|
8193223dc2 | ||
|
|
805cec5000 | ||
|
|
3151757c4f | ||
|
|
57c55e358c | ||
|
|
81b160285d | ||
|
|
77206bd126 | ||
|
|
bb22718d3d | ||
|
|
4ba3000538 | ||
|
|
e7e2c67393 | ||
|
|
47b03510b2 | ||
|
|
54c50c871a | ||
|
|
6f887712e6 | ||
|
|
99071997d5 | ||
|
|
e35a2458bb | ||
|
|
690d7418f4 | ||
|
|
aca03c924f | ||
|
|
1847736d66 | ||
|
|
e163d05683 | ||
|
|
c3b1707014 | ||
|
|
8a82288c5e | ||
|
|
8c24eb885c | ||
|
|
2afcd1ea90 | ||
|
|
68439bc599 | ||
|
|
f1186e4c84 | ||
|
|
2595cc4853 | ||
|
|
2ae28df8f8 | ||
|
|
58fd03386a | ||
|
|
890ce85cfc | ||
|
|
d5fb956161 | ||
|
|
4bad75e522 | ||
|
|
9e28e78319 | ||
|
|
d561232d19 | ||
|
|
107fe8942c | ||
|
|
06796031e6 | ||
|
|
0cfd3a2fc2 | ||
|
|
7504bf6448 | ||
|
|
09ecd67ca6 | ||
|
|
6dfa199dcb | ||
|
|
4c5148fccf | ||
|
|
108a9cf95b | ||
|
|
9885b2573a | ||
|
|
cdf4cc3d53 | ||
|
|
5b381045f9 | ||
|
|
f246c952a2 |
@@ -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 }}
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create / update the GitHub Release
|
||||
contents: write # needed to create / update the GitHub Release
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -26,15 +26,15 @@ jobs:
|
||||
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
|
||||
- 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
|
||||
- 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)
|
||||
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
|
||||
target: x86_64-apple-darwin
|
||||
name: macos-x86_64
|
||||
bin: ashell
|
||||
@@ -80,12 +80,13 @@ jobs:
|
||||
# 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/"
|
||||
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
|
||||
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
cat > "$CONTENTS/Info.plist" <<EOF
|
||||
@@ -98,6 +99,8 @@ jobs:
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ashell</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>ashell.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.ashell.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
@@ -148,9 +151,101 @@ jobs:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ env.ASSET }}
|
||||
|
||||
publish:
|
||||
name: Publish Release
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all workflow artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ env.ASSET }}
|
||||
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 uses ad-hoc signing. 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.
|
||||
|
||||
ashell 采用本地签名。安装程序包含一个后置脚本,用于移除应用程序的
|
||||
'com.apple.quarantine' 属性以允许其运行。这需要您的管理员密码。
|
||||
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
+560
-31
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.1.0"
|
||||
version = "0.4.7"
|
||||
edition = "2024"
|
||||
authors = ["ashell contributors"]
|
||||
description = "A GPUI Component based SSH and local terminal client"
|
||||
@@ -15,7 +15,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
directories = "5"
|
||||
flate2 = "1"
|
||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders"] }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
menu = { git = "https://github.com/zed-industries/zed" }
|
||||
@@ -34,13 +34,30 @@ tar = "0.4"
|
||||
thiserror = "1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time", "time"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
walkdir = "2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
image = { version = "0.25.9", default-features = false, features = ["png"] }
|
||||
futures = "0.3.32"
|
||||
notify = "6.1.1"
|
||||
open = "5.1"
|
||||
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
|
||||
tracing-appender = "0.2.5"
|
||||
argon2 = "0.5"
|
||||
base64 = "0.22"
|
||||
chacha20poly1305 = "0.10"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
|
||||
[build-dependencies]
|
||||
winres = "0.1.12"
|
||||
|
||||
+34
-3
@@ -8,14 +8,44 @@
|
||||
|
||||
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.4 Major Upgrades
|
||||
|
||||
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
|
||||
|
||||
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).
|
||||
You can download the latest pre-compiled releases for macOS, Windows, and Linux 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:
|
||||
### Method 1: Homebrew (Recommended)
|
||||
|
||||
If you use [Homebrew](https://brew.sh/), you can install it quickly with:
|
||||
|
||||
```bash
|
||||
brew install rust-kotlin/taps/ashell --cask
|
||||
```
|
||||
|
||||
To update the app:
|
||||
|
||||
```bash
|
||||
brew update
|
||||
brew upgrade ashell --cask
|
||||
```
|
||||
|
||||
> **Note**: Since the app uses ad-hoc signing, the Homebrew installation or update includes a postflight script to automatically handle the quarantine flag. This will require you to enter your administrator password for authorization.
|
||||
|
||||
### Method 2: Manual Download
|
||||
|
||||
1. Download and unzip from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
|
||||
2. Move `ashell.app` to your **Applications** folder.
|
||||
3. 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:
|
||||
|
||||
```bash
|
||||
sudo xattr -cr /Applications/ashell.app
|
||||
@@ -33,6 +63,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
|
||||
|
||||
|
||||
@@ -8,14 +8,44 @@
|
||||
|
||||
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
||||
|
||||
## 🚀 v0.4 版本重要升级
|
||||
|
||||
v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能力和更顺手的日常体验:
|
||||
- ✨ **键位管理功能**:支持在设置中可视化查看和修改常用快捷键,并提供冲突提示。
|
||||
- ✨ **设置页优化**:设置页的布局和交互更加紧凑顺手,整体信息层级更清晰。
|
||||
- ✨ **Tab 内多 Pane 与类 tmux 体验**:一个 tab 内现在可以管理多个 pane,支持分屏、聚焦和切换,提供更接近 tmux 的工作流。
|
||||
- ✨ **传输历史增强**:传输历史面板展示更完整的任务信息,便于回溯上传和下载过程。
|
||||
- ✨ **SSH passphrase 支持**:现在可以为私钥填写 passphrase,并在连接时自动使用保存的口令。
|
||||
- ✨ **终端显示优化**:终端渲染进一步增强,支持 Block Elements 等自定义图形字符的更完整显示。
|
||||
|
||||
## 下载
|
||||
|
||||
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS 和 Windows 版本的最新预编译程序。
|
||||
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
|
||||
|
||||
## Mac 安装指南
|
||||
|
||||
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
|
||||
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
|
||||
### 方法 1: Homebrew 安装 (推荐)
|
||||
|
||||
如果您使用 [Homebrew](https://brew.sh/),可以通过以下命令快速安装:
|
||||
|
||||
```bash
|
||||
brew install rust-kotlin/taps/ashell --cask
|
||||
```
|
||||
|
||||
更新应用:
|
||||
|
||||
```bash
|
||||
brew update
|
||||
brew upgrade ashell --cask
|
||||
```
|
||||
|
||||
> **注意**: 由于应用采用本地签名,Homebrew 在安装或更新过程中会执行一个后置脚本来自动处理隔离属性(quarantine flag),这需要您输入管理员密码以授权。
|
||||
|
||||
### 方法 2: 手动下载
|
||||
|
||||
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载并解压。
|
||||
2. 将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
|
||||
3. 由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
|
||||
|
||||
```bash
|
||||
sudo xattr -cr /Applications/ashell.app
|
||||
@@ -33,6 +63,7 @@ sudo xattr -cr /Applications/ashell.app
|
||||
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
|
||||
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
|
||||
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
|
||||
- **v0.3 核心增强**:全局字体与字号可调,支持 SFTP 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
@@ -0,0 +1,308 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Phyger",
|
||||
"author": "phygerr",
|
||||
"url": "https://github.com/phygerr",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Phyger Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#D6E4F0",
|
||||
"accent.foreground": "#1B3A5C",
|
||||
"background": "#F8F9FC",
|
||||
"border": "#D0D5DD",
|
||||
"danger.background": "#D94848",
|
||||
"danger.active.background": "#B53535",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"foreground": "#2B2D30",
|
||||
"input.border": "#C0C7D0",
|
||||
"link.active.foreground": "#2563EB",
|
||||
"link.foreground": "#2563EB",
|
||||
"link.hover.foreground": "#1D4ED8",
|
||||
"list.active.background": "#2563EB18",
|
||||
"list.hover.background": "#E8EDF4",
|
||||
"list.active.border": "#2563EB",
|
||||
"list.even.background": "#F0F2F7",
|
||||
"muted.background": "#E2E6EE88",
|
||||
"muted.foreground": "#7A7F88",
|
||||
"panel.background": "#ECEEF3",
|
||||
"popover.background": "#F8F9FC",
|
||||
"popover.foreground": "#2B2D30",
|
||||
"primary.active.background": "#1D4ED8",
|
||||
"primary.background": "#2563EB",
|
||||
"primary.foreground": "#FFFFFF",
|
||||
"primary.hover.background": "#3B72F0",
|
||||
"ring": "#2563EB",
|
||||
"scrollbar.background": "#F8F9FC00",
|
||||
"scrollbar.thumb.background": "#C0C7D0",
|
||||
"secondary.background": "#E8EDF4",
|
||||
"secondary.active.background": "#D0D5DD",
|
||||
"secondary.foreground": "#2B2D30",
|
||||
"secondary.hover.background": "#DCE2EB",
|
||||
"tab.active.background": "#F8F9FC",
|
||||
"tab.active.foreground": "#2B2D30",
|
||||
"tab.background": "#ECEEF300",
|
||||
"tab.foreground": "#7A7F88",
|
||||
"tab_bar.background": "#ECEEF3",
|
||||
"title_bar.background": "#ECEEF3",
|
||||
"title_bar.border": "#D0D5DD",
|
||||
"base.magenta.light": "#C2185B44",
|
||||
"base.red": "#D32F2F",
|
||||
"base.green": "#2E7D32",
|
||||
"base.yellow": "#BF8C00",
|
||||
"base.blue": "#1565C0",
|
||||
"base.magenta": "#8E24AA",
|
||||
"base.cyan": "#00838F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#2B2D30",
|
||||
"editor.background": "#F8F9FC",
|
||||
"editor.active_line.background": "#E8EDF4",
|
||||
"editor.line_number": "#A0A5AE",
|
||||
"editor.active_line_number": "#2B2D30",
|
||||
"editor.invisible": "#A0A5AE55",
|
||||
"conflict": "#D32F2F",
|
||||
"created": "#2E7D32",
|
||||
"deleted": "#C62828",
|
||||
"error": "#D32F2F",
|
||||
"hidden": "#A0A5AE",
|
||||
"hint": "#1565C0",
|
||||
"ignored": "#A0A5AE",
|
||||
"info": "#00838F",
|
||||
"modified": "#BF8C00",
|
||||
"predictive": "#A0A5AE",
|
||||
"renamed": "#BF8C00",
|
||||
"success": "#2E7D32",
|
||||
"unreachable": "#A0A5AE",
|
||||
"warning": "#E65100",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#7B1FA2"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6A737D",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6A737D",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#2B2D30"
|
||||
},
|
||||
"function": {
|
||||
"color": "#6A1B9A"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#1565C0",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#00838F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#1B5E20"
|
||||
},
|
||||
"string": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#6A1B9A",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#2E7D32"
|
||||
},
|
||||
"property": {
|
||||
"color": "#2B2D30"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#D32F2F"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Phyger Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2A3040",
|
||||
"accent.foreground": "#C8D3E0",
|
||||
"background": "#1A1D23",
|
||||
"border": "#2E3340",
|
||||
"danger.background": "#E05555",
|
||||
"danger.active.background": "#CC3D3D",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"foreground": "#C8CED8",
|
||||
"input.border": "#3A4050",
|
||||
"link.active.foreground": "#6CB4EE",
|
||||
"link.foreground": "#6CB4EE",
|
||||
"link.hover.foreground": "#8FC8F6",
|
||||
"list.active.background": "#6CB4EE15",
|
||||
"list.hover.background": "#252A34",
|
||||
"list.active.border": "#6CB4EE",
|
||||
"list.even.background": "#1E2229",
|
||||
"muted.background": "#252A3466",
|
||||
"muted.foreground": "#6B7280",
|
||||
"panel.background": "#1E2229",
|
||||
"popover.background": "#1A1D23",
|
||||
"popover.foreground": "#C8CED8",
|
||||
"primary.active.background": "#4A90D9",
|
||||
"primary.background": "#5BA0E0",
|
||||
"primary.foreground": "#1A1D23",
|
||||
"primary.hover.background": "#6CB4EE",
|
||||
"ring": "#5BA0E0",
|
||||
"scrollbar.background": "#1A1D2300",
|
||||
"scrollbar.thumb.background": "#3A4050",
|
||||
"secondary.active.background": "#333A48",
|
||||
"secondary.background": "#252A34",
|
||||
"secondary.foreground": "#C8CED8",
|
||||
"secondary.hover.background": "#2E3542",
|
||||
"tab.active.background": "#1A1D23",
|
||||
"tab.active.foreground": "#C8CED8",
|
||||
"tab.background": "#252A3400",
|
||||
"tab.foreground": "#6B7280",
|
||||
"tab_bar.background": "#1E2229DD",
|
||||
"title_bar.background": "#1E2229",
|
||||
"title_bar.border": "#2E3340",
|
||||
"base.magenta.light": "#C792EA44",
|
||||
"base.red": "#E06060",
|
||||
"base.green": "#7EC699",
|
||||
"base.yellow": "#E8C97A",
|
||||
"base.blue": "#6CB4EE",
|
||||
"base.magenta": "#C792EA",
|
||||
"base.cyan": "#56D4C7"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#C8CED8",
|
||||
"editor.background": "#1A1D23",
|
||||
"editor.active_line.background": "#222730",
|
||||
"editor.line_number": "#5A6170",
|
||||
"editor.active_line_number": "#C8CED8",
|
||||
"editor.invisible": "#5A617055",
|
||||
"conflict": "#E06060",
|
||||
"created": "#7EC699",
|
||||
"deleted": "#CC4444",
|
||||
"error": "#E06060",
|
||||
"hidden": "#6B7280",
|
||||
"hint": "#C792EA",
|
||||
"ignored": "#5A6170",
|
||||
"info": "#56D4C7",
|
||||
"modified": "#E8C97A",
|
||||
"predictive": "#4A5568",
|
||||
"renamed": "#E8C97A",
|
||||
"success": "#7EC699",
|
||||
"unreachable": "#5A6170",
|
||||
"warning": "#E8C97A",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#C792EA"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5A6A7A",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5A6A7A",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#C8CED8"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E2C08D"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#6CB4EE",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#56D4C7",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#7EC699"
|
||||
},
|
||||
"string": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"title": {
|
||||
"color": "#E2C08D",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7EC699"
|
||||
},
|
||||
"property": {
|
||||
"color": "#C8CED8"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E06060"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set_icon("assets/icons/ashell.ico");
|
||||
res.compile().unwrap();
|
||||
}
|
||||
}
|
||||
+127
-1
@@ -6,12 +6,23 @@ upload_file: "Upload File"
|
||||
upload_folder: "Upload Folder"
|
||||
hidden: "Hidden"
|
||||
download: "Download"
|
||||
download_folder: "Download Folder"
|
||||
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"
|
||||
new_folder: "New Folder"
|
||||
create_folder_success: "Created folder: %{name}"
|
||||
create_folder_failed: "Failed to create folder: %{err}"
|
||||
delete_selected: "Delete"
|
||||
confirm_delete: "Confirm Delete"
|
||||
confirm_delete_desc: "Are you sure you want to delete the selected %{count} items? This action cannot be undone."
|
||||
system_path_warning: "WARNING: You are attempting to delete critical system paths! This may cause system corruption!"
|
||||
delete_success: "Successfully deleted %{count} items"
|
||||
delete_failed: "Failed to delete: %{err}"
|
||||
deleting_paths: "Deleting %{count} items..."
|
||||
local_terminal: "Local Terminal"
|
||||
open_local_shell_tab: "Open a new local shell tab"
|
||||
connect: "Connect"
|
||||
@@ -51,10 +62,125 @@ add_ssh: "+ ssh"
|
||||
open_local_or_ssh: "Open a local terminal or pick an SSH session."
|
||||
open_session: "Open Session"
|
||||
settings: "Settings"
|
||||
ui_font_size: "UI Font Size"
|
||||
terminal_font_size: "Terminal Font Size"
|
||||
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
|
||||
system_default: "System Default"
|
||||
ui_font_family: "UI Font"
|
||||
terminal_font_family: "Terminal Font"
|
||||
monitoring_position: "Monitoring Position"
|
||||
position_bottom: "Bottom"
|
||||
position_sidebar: "Sidebar"
|
||||
position_hidden: "Hidden"
|
||||
local_host: "local host"
|
||||
local_terminal_opened: "local terminal opened"
|
||||
connecting: "Connecting"
|
||||
starting_connection: "starting connection..."
|
||||
connection_failed: "Connection Failed"
|
||||
|
||||
preview_failed: "Preview failed: %{err}"
|
||||
downloading_file: "Downloading %{base}..."
|
||||
download_failed: "Download failed: %{err}"
|
||||
uploading: "Uploading..."
|
||||
upload_failed: "Upload failed: %{err}"
|
||||
downloaded_folder: "Downloaded folder to %{path}"
|
||||
downloaded_file: "Downloaded file to %{path}"
|
||||
uploaded_file: "Uploaded file"
|
||||
uploaded_folder: "Uploaded folder"
|
||||
uploaded_n_files: "Uploaded %{files} files"
|
||||
uploaded_n_folders: "Uploaded %{folders} folders"
|
||||
uploaded_files_and_folders: "Uploaded %{files} files and %{folders} folders"
|
||||
n_files: "%{files} files"
|
||||
n_folders: "%{folders} folders"
|
||||
n_files_and_folders: "%{files} files and %{folders} folders"
|
||||
reset: "Reset"
|
||||
reset_layout: "Reset Default Layout"
|
||||
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
|
||||
|
||||
transfers: "Transfers"
|
||||
no_transfers_yet: "No transfers yet"
|
||||
downloading: "Downloading"
|
||||
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"
|
||||
keyword_highlight: "Terminal Keyword Highlighting"
|
||||
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_open_search: "Search"
|
||||
search: "Search"
|
||||
no_results: "No results"
|
||||
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'
|
||||
title_bar_style: "Title Bar Style"
|
||||
title_bar_native: "Native"
|
||||
title_bar_integrated: "Integrated"
|
||||
restart_hint: " (Restart required)"
|
||||
settings_sync: "Configuration Sync"
|
||||
sync_endpoint: "WebDAV Endpoint"
|
||||
sync_username: "WebDAV Username"
|
||||
sync_webdav_password: "WebDAV Password"
|
||||
sync_encryption_password: "Encryption Password (8+ characters)"
|
||||
sync_security_hint: "Only connection parameters are stored locally. WebDAV passwords, S3 secrets, and encryption passwords remain in memory for this process. Uploaded data is end-to-end encrypted. Downloading replaces locally saved SSH sessions."
|
||||
sync_s3_endpoint: "S3 Endpoint (optional for AWS S3)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "Object Key"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token (optional)"
|
||||
sync_download: "Download Remote"
|
||||
sync_upload: "Upload Remote"
|
||||
sync_not_run: "Not synchronized yet"
|
||||
sync_uploading: "Encrypting and uploading configuration..."
|
||||
sync_downloading: "Downloading and decrypting configuration..."
|
||||
sync_upload_complete: "Configuration uploaded"
|
||||
sync_download_complete: "Configuration downloaded"
|
||||
sync_failed: "Synchronization failed"
|
||||
keyboard_interactive: "Keyboard Interactive"
|
||||
|
||||
+129
-1
@@ -6,12 +6,24 @@ upload_file: "上传文件"
|
||||
upload_folder: "上传文件夹"
|
||||
hidden: "显示隐藏文件"
|
||||
download: "下载"
|
||||
download_folder: "下载文件夹"
|
||||
download_count: "下载 (%{count})"
|
||||
name: "名称"
|
||||
size: "大小"
|
||||
modified: "修改时间"
|
||||
new_ssh_connection: "新建 SSH 连接"
|
||||
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
|
||||
new_folder: "新建文件夹"
|
||||
create_folder_success: "成功创建文件夹: %{name}"
|
||||
create_folder_failed: "创建文件夹失败: %{err}"
|
||||
delete_selected: "删除"
|
||||
confirm_delete: "确认删除"
|
||||
confirm_delete_desc: "确定要删除以下选中的 %{count} 个项吗?此操作无法撤销。"
|
||||
system_path_warning: "警告:您即将删除系统关键路径!这可能会导致系统损坏!"
|
||||
delete_success: "成功删除 %{count} 个项"
|
||||
delete_failed: "删除失败: %{err}"
|
||||
deleting_paths: "正在删除 %{count} 个项..."
|
||||
confirm: "确认"
|
||||
local_terminal: "本地终端"
|
||||
open_local_shell_tab: "打开一个新的本地终端页面"
|
||||
connect: "连接"
|
||||
@@ -51,10 +63,126 @@ add_ssh: "+ SSH"
|
||||
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
|
||||
open_session: "打开会话"
|
||||
settings: "设置"
|
||||
ui_font_size: "界面字体大小"
|
||||
terminal_font_size: "终端字体大小"
|
||||
theme_management_hint: "主题模式和亮/暗主题系列可通过顶栏的主题按钮管理。"
|
||||
system_default: "系统默认"
|
||||
ui_font_family: "界面字体"
|
||||
terminal_font_family: "终端字体"
|
||||
monitoring_position: "系统监控显示位置"
|
||||
position_bottom: "底部"
|
||||
position_sidebar: "侧边栏"
|
||||
position_hidden: "不显示"
|
||||
local_host: "本地主机"
|
||||
local_terminal_opened: "已打开本地终端"
|
||||
connecting: "正在连接"
|
||||
starting_connection: "开始连接..."
|
||||
connection_failed: "连接失败"
|
||||
|
||||
preview_failed: "预览失败: %{err}"
|
||||
downloading_file: "正在下载 %{base}..."
|
||||
download_failed: "下载失败: %{err}"
|
||||
uploading: "正在上传..."
|
||||
upload_failed: "上传失败: %{err}"
|
||||
downloaded_folder: "已下载文件夹至 %{path}"
|
||||
downloaded_file: "已下载文件至 %{path}"
|
||||
uploaded_file: "已上传文件"
|
||||
uploaded_folder: "已上传文件夹"
|
||||
uploaded_n_files: "已上传 %{files} 个文件"
|
||||
uploaded_n_folders: "已上传 %{folders} 个文件夹"
|
||||
uploaded_files_and_folders: "已上传 %{files} 个文件及 %{folders} 个文件夹"
|
||||
n_files: "%{files} 个文件"
|
||||
n_folders: "%{folders} 个文件夹"
|
||||
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
|
||||
reset: "重置"
|
||||
reset_layout: "恢复默认布局"
|
||||
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
|
||||
|
||||
transfers: "传输记录"
|
||||
no_transfers_yet: "暂无传输记录"
|
||||
downloading: "正在下载"
|
||||
paused: "已暂停"
|
||||
completed: "已完成"
|
||||
failed: "失败"
|
||||
cancelled: "已取消"
|
||||
interrupted: "已中断"
|
||||
session: "会话"
|
||||
zombie: "僵尸任务"
|
||||
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
|
||||
|
||||
software_builtin: "软件内置"
|
||||
retry: "重试"
|
||||
right_click_copy_paste: "右键复制/粘贴"
|
||||
keyword_highlight: "终端关键词高亮"
|
||||
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_open_search: "搜索"
|
||||
search: "搜索"
|
||||
no_results: "无结果"
|
||||
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}」使用,请选择其他快捷键'
|
||||
title_bar_style: "标题栏样式"
|
||||
title_bar_native: "原生"
|
||||
title_bar_integrated: "融合"
|
||||
restart_hint: " (修改后重启生效)"
|
||||
settings_sync: "配置同步"
|
||||
sync_endpoint: "WebDAV 地址"
|
||||
sync_username: "WebDAV 用户名"
|
||||
sync_webdav_password: "WebDAV 密码"
|
||||
sync_encryption_password: "配置加密口令(至少 8 位)"
|
||||
sync_security_hint: "仅连接参数会保存到本机。WebDAV 密码、S3 密钥和加密口令只保留在当前进程内;上传内容使用端到端加密。下载会替换本机保存的 SSH 会话。"
|
||||
sync_s3_endpoint: "S3 Endpoint(AWS S3 可留空)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "对象路径"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token(可选)"
|
||||
sync_download: "从远程下载"
|
||||
sync_upload: "上传到远程"
|
||||
sync_not_run: "尚未同步"
|
||||
sync_uploading: "正在加密并上传配置..."
|
||||
sync_downloading: "正在下载并解密配置..."
|
||||
sync_upload_complete: "配置上传完成"
|
||||
sync_download_complete: "配置下载完成"
|
||||
sync_failed: "同步失败"
|
||||
keyboard_interactive: "键盘交互"
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 279 KiB |
@@ -17,6 +17,8 @@ rm -rf "$APP_DIR"
|
||||
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp "$ROOT_DIR/target/release/$APP_NAME" "$MACOS_DIR/$APP_NAME"
|
||||
|
||||
cp "$ROOT_DIR/assets/icons/ashell.icns" "$RESOURCES_DIR/ashell.icns"
|
||||
|
||||
cat > "$CONTENTS_DIR/Info.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
@@ -27,6 +29,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>ashell.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$BUNDLE_ID</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use gpui::{Context, Entity, SharedString};
|
||||
use gpui_component::input::InputState;
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{
|
||||
Ashell,
|
||||
sync::{self, SyncBackendCredentials, SyncCredentials, SyncPayload, SyncResult},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
fn sync_input_value(input: &Entity<InputState>, cx: &Context<Self>) -> String {
|
||||
input.read(cx).value().trim().to_string()
|
||||
}
|
||||
|
||||
fn sync_credentials(&self, cx: &Context<Self>) -> SyncCredentials {
|
||||
let backend = if self.config.sync_backend() == "s3" {
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint: Self::sync_input_value(&self.sync_s3_endpoint_input, cx),
|
||||
region: Self::sync_input_value(&self.sync_s3_region_input, cx),
|
||||
bucket: Self::sync_input_value(&self.sync_s3_bucket_input, cx),
|
||||
object_key: Self::sync_input_value(&self.sync_s3_object_key_input, cx),
|
||||
access_key: Self::sync_input_value(&self.sync_s3_access_key_input, cx),
|
||||
secret_key: self.sync_s3_secret_key_input.read(cx).value().to_string(),
|
||||
session_token: self
|
||||
.sync_s3_session_token_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string(),
|
||||
}
|
||||
} else {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint: Self::sync_input_value(&self.sync_endpoint_input, cx),
|
||||
username: Self::sync_input_value(&self.sync_username_input, cx),
|
||||
password: self.sync_webdav_password_input.read(cx).value().to_string(),
|
||||
}
|
||||
};
|
||||
SyncCredentials {
|
||||
backend,
|
||||
encryption_password: self
|
||||
.sync_encryption_password_input
|
||||
.read(cx)
|
||||
.value()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_sync(
|
||||
&mut self,
|
||||
status: SharedString,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Option<SyncCredentials> {
|
||||
if self.sync_in_progress {
|
||||
return None;
|
||||
}
|
||||
let credentials = self.sync_credentials(cx);
|
||||
match &credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint, username, ..
|
||||
} => {
|
||||
self.config
|
||||
.set_sync_connection(endpoint.clone(), username.clone());
|
||||
}
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
..
|
||||
} => {
|
||||
self.config.set_sync_s3_connection(
|
||||
endpoint.clone(),
|
||||
region.clone(),
|
||||
bucket.clone(),
|
||||
object_key.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(err) = self.config.save() {
|
||||
self.sync_status = format!("{}: {err:#}", t!("sync_failed")).into();
|
||||
cx.notify();
|
||||
return None;
|
||||
}
|
||||
self.sync_in_progress = true;
|
||||
self.sync_status = status;
|
||||
cx.notify();
|
||||
Some(credentials)
|
||||
}
|
||||
|
||||
pub(crate) fn set_sync_backend(&mut self, backend: &str, cx: &mut Context<Self>) {
|
||||
self.config.set_sync_backend(backend);
|
||||
let _ = self.config.save();
|
||||
self.sync_status = t!("sync_not_run").into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sync_config(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(credentials) = self.begin_sync(t!("sync_uploading").into(), cx) else {
|
||||
return;
|
||||
};
|
||||
let payload = SyncPayload::new(
|
||||
self.config.sync_device_id().to_string(),
|
||||
self.config.sessions().to_vec(),
|
||||
);
|
||||
let expected_etag = self.config.sync_etag().map(str::to_string);
|
||||
let events = self.events_tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let result = match sync::upload(credentials, payload, expected_etag).await {
|
||||
Ok(etag) => SyncResult::Uploaded { etag },
|
||||
Err(err) => SyncResult::Failed(format!("{err:#}")),
|
||||
};
|
||||
let _ = events.send(BackendEvent::SyncFinished(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn download_sync_config(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(credentials) = self.begin_sync(t!("sync_downloading").into(), cx) else {
|
||||
return;
|
||||
};
|
||||
let events = self.events_tx.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let result = match sync::download(credentials).await {
|
||||
Ok((payload, etag)) => SyncResult::Downloaded { payload, etag },
|
||||
Err(err) => SyncResult::Failed(format!("{err:#}")),
|
||||
};
|
||||
let _ = events.send(BackendEvent::SyncFinished(result));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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;
|
||||
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
|
||||
+1918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
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,
|
||||
OpenSearch,
|
||||
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: "OpenSearch",
|
||||
label_key: "settings_open_search",
|
||||
default_suffix: "f",
|
||||
},
|
||||
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!("OpenSearch", crate::OpenSearch);
|
||||
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!("OpenSearch", crate::OpenSearch);
|
||||
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", "OpenSearch"],
|
||||
),
|
||||
(
|
||||
"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
|
||||
}
|
||||
}
|
||||
+1069
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{
|
||||
Context, Focusable as _, Hsla, IntoElement, InteractiveElement as _, MouseButton,
|
||||
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
|
||||
};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
|
||||
button::{Button, ButtonVariants as _},
|
||||
h_flex, input::Input,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::Ashell;
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.search_active {
|
||||
self.close_search(window, cx);
|
||||
} else {
|
||||
self.open_search(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.search_active = true;
|
||||
// Focus the search input on the next frame so it happens after the
|
||||
// current render cycle completes, avoiding focus being stolen back
|
||||
// by the terminal panel's track_focus.
|
||||
let search_input = self.search_input.clone();
|
||||
cx.on_next_frame(window, move |_this, window, cx| {
|
||||
search_input.update(cx, |state, cx| {
|
||||
state.focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.search_active = false;
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.search_target_tab = None;
|
||||
self.search_bar_bounds = None;
|
||||
self.focus_handle.focus(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Move keyboard focus back to the search input so the user can keep typing.
|
||||
/// Deferred to the next frame so it happens after the current render cycle,
|
||||
/// preventing the terminal panel's track_focus from stealing focus back.
|
||||
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let search_input = self.search_input.clone();
|
||||
cx.on_next_frame(window, move |_this, window, cx| {
|
||||
search_input.update(cx, |state, cx| {
|
||||
state.focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn perform_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let query = self.search_input.read(cx).text().to_string();
|
||||
if query.is_empty() {
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the active tab — try active_tab first, then fall back to the
|
||||
// first tab in the active group, then any tab.
|
||||
let tab = self
|
||||
.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
|
||||
|
||||
let tab = tab.or_else(|| {
|
||||
let first_id = self
|
||||
.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
|
||||
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
|
||||
.map(|s| s.to_string());
|
||||
first_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
|
||||
});
|
||||
|
||||
let tab = tab.or_else(|| self.tabs.first());
|
||||
|
||||
let Some(tab) = tab else {
|
||||
self.status = t!("no_results").into();
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
// Remember which tab was searched so highlights only appear in that pane.
|
||||
self.search_target_tab = Some(tab.id.clone());
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_byte_len = query.len();
|
||||
|
||||
// Search the ENTIRE terminal buffer (scrollback + visible screen).
|
||||
// full_grid_rows returns grid line indices; the first row is at
|
||||
// `grid_start` (typically -history_size).
|
||||
let (grid_start, all_rows) = tab.full_grid_rows();
|
||||
|
||||
let mut matches: Vec<(i32, i32)> = Vec::new();
|
||||
|
||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||
if row.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build text string and byte→column index mapping.
|
||||
let mut text = String::with_capacity(row.len());
|
||||
let mut byte_to_col: Vec<i32> = Vec::new();
|
||||
for &(col, c) in row {
|
||||
text.push(c);
|
||||
while byte_to_col.len() < text.len() {
|
||||
byte_to_col.push(col);
|
||||
}
|
||||
}
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
// Grid line index for this row.
|
||||
let abs_row = grid_start + row_idx as i32;
|
||||
|
||||
let mut search_start = 0;
|
||||
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
|
||||
let abs = search_start + pos;
|
||||
let start_col = byte_to_col[abs];
|
||||
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
|
||||
let end_col = byte_to_col[end_byte - 1];
|
||||
for c in start_col..=end_col {
|
||||
matches.push((abs_row, c));
|
||||
}
|
||||
search_start = abs + query_byte_len;
|
||||
}
|
||||
}
|
||||
|
||||
let match_count = count_match_groups(&matches);
|
||||
|
||||
self.search_query = query;
|
||||
self.search_matches = matches;
|
||||
|
||||
if match_count > 0 {
|
||||
self.search_current = 0;
|
||||
self.jump_to_current_match(cx);
|
||||
}
|
||||
|
||||
self.status = format!(
|
||||
"{}: {} ({})",
|
||||
t!("search"),
|
||||
self.search_query,
|
||||
if match_count == 0 {
|
||||
t!("no_results").to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.search_current + 1, match_count)
|
||||
}
|
||||
)
|
||||
.into();
|
||||
|
||||
// Keep focus on the search input so the user can continue typing.
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
if match_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.search_current = (self.search_current + 1) % match_count;
|
||||
self.jump_to_current_match(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
if match_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.search_current = (self.search_current + match_count - 1) % match_count;
|
||||
self.jump_to_current_match(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
|
||||
// target_grid_line is the grid line index (negative = history).
|
||||
let Some((target_grid_line, _)) =
|
||||
find_nth_match_start(&self.search_matches, self.search_current)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the tab ID first (immutable borrow), then look up mutably.
|
||||
let tab_id = self.active_tab.clone().or_else(|| {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
|
||||
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let tab = if let Some(id) = tab_id.as_deref() {
|
||||
self.tabs.iter_mut().find(|t| t.id == id)
|
||||
} else {
|
||||
self.tabs.first_mut()
|
||||
};
|
||||
|
||||
if let Some(tab) = tab {
|
||||
let snapshot = tab.render_snapshot();
|
||||
let display_offset = snapshot.display_offset as i32;
|
||||
let rows = snapshot.rows as i32;
|
||||
|
||||
// viewport row = grid_line + display_offset
|
||||
let vp_row = target_grid_line + display_offset;
|
||||
let visible = vp_row >= 0 && vp_row < rows;
|
||||
|
||||
if !visible {
|
||||
// Scroll so the target grid line appears near the top.
|
||||
// display_offset = -grid_line puts it at viewport row 0.
|
||||
let new_offset = (-target_grid_line).max(0) as usize;
|
||||
if new_offset > snapshot.display_offset {
|
||||
tab.scroll_up_by(new_offset - snapshot.display_offset);
|
||||
} else if new_offset < snapshot.display_offset {
|
||||
tab.scroll_down_by(snapshot.display_offset - new_offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a highlight map for search matches, converting grid line indices
|
||||
/// to the current viewport coordinates. Only returns highlights for the
|
||||
/// pane that was actually searched (`search_target_tab`).
|
||||
pub(crate) fn search_highlight_map(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
match_color: Hsla,
|
||||
current_color: Hsla,
|
||||
) -> Option<HashMap<(i32, i32), Hsla>> {
|
||||
if self.search_matches.is_empty()
|
||||
|| self.search_query.is_empty()
|
||||
|| self.search_target_tab.as_deref() != Some(tab_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get current display_offset to convert grid line → viewport row.
|
||||
let tab = self
|
||||
.active_tab
|
||||
.as_ref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| &t.id == id));
|
||||
|
||||
let tab = tab.or_else(|| {
|
||||
let first_id = self
|
||||
.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
|
||||
.and_then(|g| g.pane_root.tab_ids().into_iter().next())
|
||||
.map(|s| s.to_string());
|
||||
first_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.tabs.iter().find(|t| t.id == id))
|
||||
});
|
||||
|
||||
let tab = tab.or_else(|| self.tabs.first());
|
||||
|
||||
let Some(tab) = tab else {
|
||||
return None;
|
||||
};
|
||||
let snapshot = tab.render_snapshot();
|
||||
let display_offset = snapshot.display_offset as i32;
|
||||
let rows = snapshot.rows as i32;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
let mut sorted: Vec<(i32, i32)> = self.search_matches.clone();
|
||||
sorted.sort();
|
||||
|
||||
let mut group_idx = 0;
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
let is_current = group_idx == self.search_current;
|
||||
let color = if is_current {
|
||||
current_color
|
||||
} else {
|
||||
match_color
|
||||
};
|
||||
|
||||
// grid_line → viewport row: vp_row = grid_line + display_offset
|
||||
let (grid_line, _) = sorted[i];
|
||||
let vp_row = grid_line + display_offset;
|
||||
let mut j = i;
|
||||
if vp_row >= 0 && vp_row < rows {
|
||||
while j < sorted.len() && sorted[j].0 == grid_line {
|
||||
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
|
||||
break;
|
||||
}
|
||||
map.insert((vp_row, sorted[j].1), color);
|
||||
j += 1;
|
||||
}
|
||||
} else {
|
||||
// Outside current viewport — skip.
|
||||
while j < sorted.len() && sorted[j].0 == grid_line {
|
||||
if j > i && sorted[j].1 != sorted[j - 1].1 + 1 {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
|
||||
group_idx += 1;
|
||||
i = j;
|
||||
}
|
||||
|
||||
Some(map)
|
||||
}
|
||||
|
||||
/// Render the search button (used in the tab bar).
|
||||
pub(crate) fn render_search_button(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
|
||||
div()
|
||||
.child(
|
||||
Button::new("search-btn")
|
||||
.ghost()
|
||||
.small()
|
||||
.rounded(px(999.))
|
||||
.icon(IconName::Search)
|
||||
.tooltip(t!("search").to_string())
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_search(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the expanded search bar overlay (when search is active).
|
||||
pub(crate) fn render_search_bar(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
let has_query = !self.search_query.is_empty();
|
||||
let has_matches = match_count > 0;
|
||||
let current_display = if has_matches {
|
||||
format!("{}/{}", self.search_current + 1, match_count)
|
||||
} else if has_query {
|
||||
"0".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let view = cx.entity();
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(8.))
|
||||
.right(px(24.))
|
||||
.on_prepaint(move |bounds, _window, cx| {
|
||||
let _ = view.update(cx, |this, _| {
|
||||
this.search_bar_bounds = Some(bounds);
|
||||
});
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| {
|
||||
this.refocus_search_input(window, cx);
|
||||
cx.stop_propagation();
|
||||
}))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.p_1()
|
||||
.rounded(px(6.))
|
||||
.bg(cx.theme().popover)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.w(px(200.))
|
||||
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key.as_str() == "escape" {
|
||||
this.close_search(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}))
|
||||
.child(Input::new(&self.search_input).small()),
|
||||
)
|
||||
.when(!current_display.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_size(rems(0.75))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.min_w(px(36.))
|
||||
.text_center()
|
||||
.child(current_display),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("search-prev")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::ChevronUp)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.search_goto_prev(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("search-next")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::ChevronDown)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.search_goto_next(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("search-close")
|
||||
.ghost()
|
||||
.xsmall()
|
||||
.icon(IconName::Close)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.close_search(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Count distinct match groups in a sorted list of (row, col) positions.
|
||||
/// A group is a run of consecutive columns in the same row.
|
||||
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
|
||||
if matches.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
|
||||
sorted.sort();
|
||||
let mut count = 0;
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
count += 1;
|
||||
let (r, _) = sorted[i];
|
||||
i += 1;
|
||||
// Skip consecutive columns in the same row.
|
||||
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Find the (row, col) start of the Nth distinct match group.
|
||||
fn find_nth_match_start(matches: &[(i32, i32)], n: usize) -> Option<(i32, i32)> {
|
||||
if matches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<(i32, i32)> = matches.to_vec();
|
||||
sorted.sort();
|
||||
let mut group_idx = 0;
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
if group_idx == n {
|
||||
return Some(sorted[i]);
|
||||
}
|
||||
group_idx += 1;
|
||||
let (r, _) = sorted[i];
|
||||
i += 1;
|
||||
while i < sorted.len() && sorted[i].0 == r && sorted[i].1 == sorted[i - 1].1 + 1 {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
|
||||
use gpui_component::Root;
|
||||
|
||||
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,
|
||||
prefix: String,
|
||||
current_minute: u32,
|
||||
file: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl LocalMinutelyRoller {
|
||||
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
prefix,
|
||||
current_minute: 60,
|
||||
file: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
|
||||
use chrono::Timelike;
|
||||
let minute = now.minute();
|
||||
if self.current_minute != minute || self.file.is_none() {
|
||||
let filename = format!("{}-{}.log", self.prefix, now.format("%Y-%m-%d-%H-%M"));
|
||||
let path = self.dir.join(filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
self.file = Some(file);
|
||||
self.current_minute = minute;
|
||||
|
||||
// Cleanup old files keeping last 6
|
||||
if let Ok(entries) = std::fs::read_dir(&self.dir) {
|
||||
let mut files: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
|
||||
.collect();
|
||||
files.sort_by_key(|e| {
|
||||
e.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
if files.len() > 6 {
|
||||
for file in files.iter().take(files.len() - 6) {
|
||||
let _ = std::fs::remove_file(file.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for LocalMinutelyRoller {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let now = chrono::Local::now();
|
||||
let _ = self.rollover(now);
|
||||
if let Some(f) = &mut self.file {
|
||||
f.write(buf)
|
||||
} else {
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
if let Some(f) = &mut self.file {
|
||||
f.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn init_logging() {
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
let log_dir = directories::BaseDirs::new()
|
||||
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
|
||||
std::fs::create_dir_all(&log_dir).ok();
|
||||
|
||||
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
|
||||
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
|
||||
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
|
||||
std::mem::forget(_guard);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
let stdout_layer = if cfg!(debug_assertions) {
|
||||
Some(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||
.with_target(true),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(stdout_layer)
|
||||
.with(file_layer)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn sync_macos_launch_environment() {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
let Ok(output) = std::process::Command::new(&shell)
|
||||
.args(["-l", "-c", "env -0"])
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in output.stdout.split(|b| *b == 0) {
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(eq) = entry.iter().position(|b| *b == b'=') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(key) = std::str::from_utf8(&entry[..eq]) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = std::str::from_utf8(&entry[eq + 1..]) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let should_import = matches!(
|
||||
key,
|
||||
"PATH"
|
||||
| "MANPATH"
|
||||
| "INFOPATH"
|
||||
| "LANG"
|
||||
| "LC_ALL"
|
||||
| "LC_CTYPE"
|
||||
| "SHELL"
|
||||
| "HOME"
|
||||
| "HOMEBREW_PREFIX"
|
||||
| "HOMEBREW_CELLAR"
|
||||
| "HOMEBREW_REPOSITORY"
|
||||
) || key.starts_with("LC_");
|
||||
|
||||
if should_import {
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn sync_macos_launch_environment() {}
|
||||
|
||||
pub(crate) fn open_main_window(cx: &mut App) {
|
||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
|
||||
let mut window_options = WindowOptions::default();
|
||||
|
||||
if config.title_bar_style() == crate::session::config::TitleBarStyle::Integrated {
|
||||
window_options.titlebar = Some(gpui::TitlebarOptions {
|
||||
title: None,
|
||||
appears_transparent: true,
|
||||
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
|
||||
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
|
||||
}
|
||||
|
||||
if let Some(bounds) = config.window_bounds() {
|
||||
window_options.window_bounds = Some(match bounds {
|
||||
crate::session::config::SavedWindowBounds::Fullscreen {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Fullscreen(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::session::config::SavedWindowBounds::Maximized {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Maximized(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
crate::session::config::SavedWindowBounds::Windowed {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
} => gpui::WindowBounds::Windowed(Bounds::new(
|
||||
point(px(*x), px(*y)),
|
||||
size(px(*width), px(*height)),
|
||||
)),
|
||||
});
|
||||
} else 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");
|
||||
gpui_component::Theme::sync_system_appearance(Some(window), cx);
|
||||
let view = cx.new(|cx| Ashell::new(window, cx));
|
||||
|
||||
tracing::info!("[ui] main application window opened");
|
||||
let focus_handle = view.read(cx).focus_handle.clone();
|
||||
window.focus(&focus_handle, 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;
|
||||
}
|
||||
let current_bounds = window.window_bounds();
|
||||
let bounds = match current_bounds {
|
||||
gpui::WindowBounds::Fullscreen(b) => b,
|
||||
gpui::WindowBounds::Maximized(b) => b,
|
||||
gpui::WindowBounds::Windowed(b) => b,
|
||||
};
|
||||
let size = bounds.size;
|
||||
if size.width.as_f32() > 400.0 && size.height.as_f32() > 300.0 {
|
||||
tracing::info!("[ui] main application window closed, saving layout state...");
|
||||
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
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(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let workspace_sizes: Vec<f32> = workspace_panels_clone
|
||||
.read(cx)
|
||||
.sizes()
|
||||
.iter()
|
||||
.map(|s| s.into())
|
||||
.collect();
|
||||
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();
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[ui] window size is too small ({:?}), skipping save layout state to prevent corrupting saved bounds.",
|
||||
size
|
||||
);
|
||||
}
|
||||
true
|
||||
});
|
||||
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
})
|
||||
.expect("failed to open window");
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use gpui::{App, Context, SharedString, Window, px};
|
||||
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
|
||||
|
||||
use crate::Ashell;
|
||||
|
||||
pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
|
||||
include_str!("../../assets/themes/matrix.json"),
|
||||
include_str!("../../assets/themes/tokyonight.json"),
|
||||
include_str!("../../assets/themes/gruvbox.json"),
|
||||
include_str!("../../assets/themes/solarized.json"),
|
||||
include_str!("../../assets/themes/phygerr.json"),
|
||||
];
|
||||
|
||||
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(),
|
||||
);
|
||||
cx.text_system()
|
||||
.add_fonts(vec![regular, bold])
|
||||
.context("load Maple Mono NF CN fonts")?;
|
||||
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn load_embedded_themes(cx: &mut App) {
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for theme_json in EMBEDDED_THEME_JSONS {
|
||||
if let Err(err) = registry.load_themes_from_str(theme_json) {
|
||||
tracing::warn!("failed to load embedded theme: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
|
||||
theme.font_family = ui_font_family.into();
|
||||
theme.mono_font_family = ui_font_family.into();
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
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);
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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();
|
||||
return;
|
||||
};
|
||||
|
||||
if theme_config.mode.is_dark() {
|
||||
self.dark_theme_name = name.clone();
|
||||
} else {
|
||||
self.light_theme_name = name.clone();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.status = format!("theme: {name}").into();
|
||||
self.persist_theme_preferences();
|
||||
window.refresh();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_follow_system_theme(
|
||||
&mut self,
|
||||
follow: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.follow_system_theme = follow;
|
||||
if follow {
|
||||
self.status = "theme mode: system".into();
|
||||
} else {
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
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" {
|
||||
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();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let light_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.light_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_light_theme().clone());
|
||||
let dark_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.dark_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
|
||||
let theme = Theme::global_mut(cx);
|
||||
theme.light_theme = light_theme;
|
||||
theme.dark_theme = dark_theme;
|
||||
theme.font_size = px(self.ui_font_size);
|
||||
set_theme_font_names(theme, &self.ui_font_family);
|
||||
|
||||
if self.follow_system_theme {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
} else {
|
||||
Theme::change(self.theme_mode, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_theme_preferences(&mut self) {
|
||||
let theme_mode_str = match self.theme_mode {
|
||||
ThemeMode::Light => "light",
|
||||
ThemeMode::Dark => "dark",
|
||||
};
|
||||
self.config.set_theme_preferences(
|
||||
self.follow_system_theme,
|
||||
theme_mode_str,
|
||||
self.light_theme_name.to_string(),
|
||||
self.dark_theme_name.to_string(),
|
||||
);
|
||||
if let Err(err) = self.config.save() {
|
||||
tracing::warn!("failed to save theme preferences: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2887
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
|
||||
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
|
||||
|
||||
@@ -34,7 +34,10 @@ pub fn spawn_local_terminal(
|
||||
});
|
||||
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()));
|
||||
cmd.env(
|
||||
"TERM",
|
||||
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
|
||||
);
|
||||
cmd.env(
|
||||
"COLORTERM",
|
||||
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
|
||||
@@ -91,27 +94,41 @@ 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 => {}
|
||||
BackendCommand::PromptResponse(_) => {}
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
let _ = write_events.send(BackendEvent::Closed {
|
||||
tab_id: write_tab.clone(),
|
||||
reason: format!("local write error: {err}"),
|
||||
tab_id: write_tab,
|
||||
reason: format!("local shell exited: {status}"),
|
||||
});
|
||||
break;
|
||||
return;
|
||||
}
|
||||
let _ = writer.flush();
|
||||
}
|
||||
BackendCommand::Resize { cols, rows } => {
|
||||
let _ = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod local;
|
||||
pub mod ssh;
|
||||
@@ -0,0 +1,713 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use directories::BaseDirs;
|
||||
use russh::{
|
||||
ChannelMsg, Disconnect,
|
||||
client::{self, Handler},
|
||||
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
session::config::{AuthMethod, Session},
|
||||
system::{SystemSnapshot, remote_snapshot_from_kv},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx, PromptType, PromptInfo},
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedCreds {
|
||||
pub password: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
pub kb_responses: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub static CREDENTIALS_CACHE: OnceLock<std::sync::Mutex<HashMap<String, CachedCreds>>> = OnceLock::new();
|
||||
|
||||
pub static PROMPT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
pub fn spawn_ssh_terminal(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> BackendTx {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
|
||||
let task_tab = tab_id.clone();
|
||||
runtime.spawn(async move {
|
||||
if let Err(err) = run_ssh(
|
||||
task_tab.clone(),
|
||||
session,
|
||||
cols,
|
||||
rows,
|
||||
cmd_rx,
|
||||
events.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: task_tab,
|
||||
reason: format!("{err:#}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
BackendTx::Ssh(cmd_tx)
|
||||
}
|
||||
|
||||
async fn sample_remote_system_with_handle(
|
||||
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
|
||||
) -> Result<SystemSnapshot> {
|
||||
let mut channel = handle
|
||||
.lock()
|
||||
.await
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open metrics session")?;
|
||||
channel
|
||||
.exec(true, REMOTE_SYSTEM_PROBE)
|
||||
.await
|
||||
.context("exec remote metrics probe")?;
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
|
||||
stdout.extend_from_slice(&data);
|
||||
}
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let output = String::from_utf8_lossy(&stdout);
|
||||
remote_snapshot_from_kv(&output)
|
||||
}
|
||||
|
||||
async fn run_ssh(
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<()> {
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!(
|
||||
"connecting {}@{}:{}...",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
});
|
||||
|
||||
let handle = Arc::new(tokio::sync::Mutex::new(
|
||||
connect_and_authenticate(&tab_id, &session, &events, &mut commands).await?,
|
||||
));
|
||||
|
||||
let mut channel = handle
|
||||
.lock()
|
||||
.await
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open session")?;
|
||||
channel
|
||||
.request_pty(true, "xterm-256color", cols.into(), rows.into(), 0, 0, &[])
|
||||
.await
|
||||
.context("request pty")?;
|
||||
channel.request_shell(true).await.context("request shell")?;
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("connected {}@{}", session.user, session.host),
|
||||
});
|
||||
let _ = events.send(BackendEvent::Connected {
|
||||
tab_id: tab_id.clone(),
|
||||
});
|
||||
|
||||
let exit_reason;
|
||||
let mut is_graceful_close = false;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => {
|
||||
match command {
|
||||
Some(BackendCommand::Input(bytes)) => {
|
||||
if let Err(err) = channel.data(bytes.as_slice()).await {
|
||||
tracing::error!("[ssh] write error on tab {}: {}", tab_id, err);
|
||||
exit_reason = format!("ssh write error: {err}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(BackendCommand::Resize { cols, rows }) => {
|
||||
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
|
||||
}
|
||||
Some(BackendCommand::SampleMetrics) => {
|
||||
let handle_clone = handle.clone();
|
||||
let tab_id_clone = tab_id.clone();
|
||||
let events_clone = events.clone();
|
||||
tokio::spawn(async move {
|
||||
match sample_remote_system_with_handle(handle_clone).await {
|
||||
Ok(snapshot) => {
|
||||
let _ = events_clone.send(BackendEvent::RemoteSystem {
|
||||
tab_id: tab_id_clone,
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events_clone.send(BackendEvent::RemoteSystemUnavailable {
|
||||
tab_id: tab_id_clone,
|
||||
reason: format!("remote metrics unavailable: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Some(BackendCommand::PromptResponse(_)) => {
|
||||
tracing::warn!("[ssh] received unexpected prompt response after authentication");
|
||||
}
|
||||
Some(BackendCommand::Close) | None => {
|
||||
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
|
||||
let _ = channel.eof().await;
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = channel.wait() => {
|
||||
match msg {
|
||||
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
|
||||
let _ = events.send(BackendEvent::Output {
|
||||
tab_id: tab_id.clone(),
|
||||
bytes: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Some(ChannelMsg::ExitStatus { exit_status: _ }) | Some(ChannelMsg::Eof) => {
|
||||
is_graceful_close = true;
|
||||
}
|
||||
Some(ChannelMsg::Close) => {
|
||||
if is_graceful_close {
|
||||
tracing::info!("[ssh] session gracefully closed by server for tab {}", tab_id);
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
} else {
|
||||
tracing::warn!("[ssh] connection abruptly closed by server for tab {}", tab_id);
|
||||
exit_reason = "ssh connection lost (abrupt close)".to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
if is_graceful_close {
|
||||
tracing::info!("[ssh] network stream ended gracefully for tab {}", tab_id);
|
||||
exit_reason = "ssh session closed".to_string();
|
||||
} else {
|
||||
tracing::warn!("[ssh] network drop detected for tab {}", tab_id);
|
||||
exit_reason = "ssh connection lost (network drop)".to_string();
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.lock()
|
||||
.await
|
||||
.disconnect(Disconnect::ByApplication, "bye", "")
|
||||
.await;
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id,
|
||||
reason: exit_reason,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_session_private_key_with_cache(session: &Session) -> Result<PrivateKey> {
|
||||
let cached_passphrase = {
|
||||
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
|
||||
let cache = cache_lock.lock().unwrap();
|
||||
cache.get(&session.id).and_then(|c| c.passphrase.clone())
|
||||
};
|
||||
if let Some(p) = cached_passphrase {
|
||||
let mut temp_session = session.clone();
|
||||
temp_session.passphrase = p;
|
||||
if let Ok(key) = load_session_private_key(&temp_session) {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
load_session_private_key(session)
|
||||
}
|
||||
|
||||
async fn connect_and_authenticate(
|
||||
tab_id: &str,
|
||||
session: &Session,
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
commands: &mut mpsc::UnboundedReceiver<BackendCommand>,
|
||||
) -> Result<russh::client::Handle<ClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
keepalive_interval: Some(std::time::Duration::from_secs(10)),
|
||||
keepalive_max: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
tracing::info!(
|
||||
"[ssh] initiating tcp connection to {} (user: {})",
|
||||
addr,
|
||||
session.user
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("opening tcp connection to {addr}"),
|
||||
});
|
||||
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
tracing::debug!("[ssh] tcp connected to {}", addr);
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => {
|
||||
tracing::info!(
|
||||
"[ssh] sending password authentication for {}@{}",
|
||||
session.user,
|
||||
addr
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"connected to {addr}, sending password authentication for {}",
|
||||
session.user
|
||||
),
|
||||
});
|
||||
handle
|
||||
.authenticate_password(&session.user, &session.password)
|
||||
.await
|
||||
.context("password authentication failed")?
|
||||
}
|
||||
AuthMethod::Key => {
|
||||
let source = key_source_label(session);
|
||||
tracing::info!(
|
||||
"[ssh] sending key authentication for {}@{} (key source: {})",
|
||||
session.user,
|
||||
addr,
|
||||
source
|
||||
);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
});
|
||||
let mut keypair = load_session_private_key_with_cache(session).await;
|
||||
if let Err(e) = &keypair {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("encrypted") || err_str.contains("passphrase") || err_str.contains("decrypt") {
|
||||
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
|
||||
let _guard = prompt_lock.lock().await;
|
||||
let _ = events.send(BackendEvent::PromptRequest {
|
||||
tab_id: tab_id.to_string(),
|
||||
prompt_type: PromptType::Passphrase,
|
||||
instruction: format!("Enter passphrase for private key (source: {})", source),
|
||||
prompts: vec![PromptInfo {
|
||||
prompt: "Passphrase".to_string(),
|
||||
echo: false,
|
||||
}],
|
||||
});
|
||||
let mut passphrase_res = None;
|
||||
while let Some(cmd) = commands.recv().await {
|
||||
match cmd {
|
||||
BackendCommand::PromptResponse(responses) => {
|
||||
if let Some(p) = responses.first().cloned() {
|
||||
passphrase_res = Some(p);
|
||||
}
|
||||
break;
|
||||
}
|
||||
BackendCommand::Close => {
|
||||
return Err(anyhow!("Authentication cancelled by user"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(p) = passphrase_res {
|
||||
{
|
||||
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
|
||||
let mut cache = cache_lock.lock().unwrap();
|
||||
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
|
||||
password: None,
|
||||
passphrase: None,
|
||||
kb_responses: None,
|
||||
}).passphrase = Some(p.clone());
|
||||
}
|
||||
let mut temp_session = session.clone();
|
||||
temp_session.passphrase = p;
|
||||
keypair = load_session_private_key(&temp_session);
|
||||
} else {
|
||||
return Err(anyhow!("Passphrase prompt cancelled"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let keypair = keypair.context("failed to load private key")?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
|
||||
let mut success = false;
|
||||
for key in keys {
|
||||
match handle.authenticate_publickey(&session.user, key).await {
|
||||
Ok(true) => {
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(false) => {
|
||||
tracing::debug!("[ssh] public key auth failed with algorithm, trying next");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("[ssh] public key auth error: {:?}, trying next", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !success {
|
||||
return Err(anyhow::anyhow!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
source,
|
||||
algorithm
|
||||
));
|
||||
}
|
||||
success
|
||||
}
|
||||
AuthMethod::KeyboardInteractive => {
|
||||
let cached = {
|
||||
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
|
||||
let cache = cache_lock.lock().unwrap();
|
||||
cache.get(&session.id).cloned()
|
||||
};
|
||||
let mut response = if cached.as_ref().and_then(|c| c.kb_responses.as_ref()).is_some() {
|
||||
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
|
||||
} else {
|
||||
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
|
||||
let _guard = prompt_lock.lock().await;
|
||||
handle.authenticate_keyboard_interactive_start(&session.user, None).await?
|
||||
};
|
||||
loop {
|
||||
match response {
|
||||
russh::client::KeyboardInteractiveAuthResponse::Success => {
|
||||
break true;
|
||||
}
|
||||
russh::client::KeyboardInteractiveAuthResponse::Failure => {
|
||||
break false;
|
||||
}
|
||||
russh::client::KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => {
|
||||
let mut responses = Vec::new();
|
||||
let mut cache_hit = false;
|
||||
if let Some(c) = cached.as_ref().and_then(|c| c.kb_responses.as_ref()) {
|
||||
if c.len() == prompts.len() {
|
||||
responses = c.clone();
|
||||
cache_hit = true;
|
||||
}
|
||||
}
|
||||
if !cache_hit {
|
||||
let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(()));
|
||||
let _guard = prompt_lock.lock().await;
|
||||
let mut prompt_infos = Vec::new();
|
||||
for p in &prompts {
|
||||
prompt_infos.push(PromptInfo {
|
||||
prompt: p.prompt.clone(),
|
||||
echo: p.echo,
|
||||
});
|
||||
}
|
||||
let _ = events.send(BackendEvent::PromptRequest {
|
||||
tab_id: tab_id.to_string(),
|
||||
prompt_type: PromptType::KeyboardInteractive,
|
||||
instruction: format!("{}\n{}", name, instructions),
|
||||
prompts: prompt_infos,
|
||||
});
|
||||
let mut prompt_res = None;
|
||||
while let Some(cmd) = commands.recv().await {
|
||||
match cmd {
|
||||
BackendCommand::PromptResponse(res) => {
|
||||
prompt_res = Some(res);
|
||||
break;
|
||||
}
|
||||
BackendCommand::Close => {
|
||||
return Err(anyhow!("Authentication cancelled"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(res) = prompt_res {
|
||||
responses = res;
|
||||
let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
|
||||
let mut cache = cache_lock.lock().unwrap();
|
||||
cache.entry(session.id.clone()).or_insert_with(|| CachedCreds {
|
||||
password: None,
|
||||
passphrase: None,
|
||||
kb_responses: None,
|
||||
}).kb_responses = Some(responses.clone());
|
||||
} else {
|
||||
return Err(anyhow!("Keyboard-interactive cancelled"));
|
||||
}
|
||||
}
|
||||
response = handle.authenticate_keyboard_interactive_respond(responses).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
tracing::warn!("[ssh] authentication failed for {}@{}", session.user, addr);
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
match session.auth {
|
||||
AuthMethod::Password => format!(
|
||||
"authentication failed: server rejected password authentication for {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
AuthMethod::Key => format!(
|
||||
"authentication failed: server rejected public key authentication for {}@{}:{} using {}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
key_source_label(session)
|
||||
),
|
||||
AuthMethod::KeyboardInteractive => format!(
|
||||
"authentication failed: server rejected keyboard-interactive authentication for {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[ssh] authentication successful for {}@{}",
|
||||
session.user,
|
||||
addr
|
||||
);
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"authentication accepted, opening shell for {}@{}",
|
||||
session.user, session.host
|
||||
),
|
||||
});
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if !has_inline && !has_path {
|
||||
return Err(anyhow!("private key content or path is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if has_inline {
|
||||
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(), passphrase) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_keys_with_algs(keypair: PrivateKey) -> Result<Vec<PrivateKeyWithHashAlg>> {
|
||||
let mut algs = Vec::new();
|
||||
let key_arc = Arc::new(keypair);
|
||||
|
||||
if key_arc.algorithm().is_rsa() {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha512)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), Some(HashAlg::Sha256)) {
|
||||
algs.push(k);
|
||||
}
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
} else {
|
||||
if let Ok(k) = PrivateKeyWithHashAlg::new(key_arc.clone(), None) {
|
||||
algs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
if algs.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"Failed to construct PrivateKeyWithHashAlg for any supported hash algorithm"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(algs)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value == "~" {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("~/") {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
|
||||
}
|
||||
Some(Path::new(value).to_path_buf())
|
||||
}
|
||||
|
||||
fn key_source_label(session: &Session) -> String {
|
||||
let path = session.private_key_path.trim();
|
||||
let has_inline = !session.private_key_inline.trim().is_empty();
|
||||
match (!path.is_empty(), has_inline) {
|
||||
(true, true) => format!("inline key or {}", path),
|
||||
(true, false) => path.to_string(),
|
||||
(false, true) => "inline key text".to_string(),
|
||||
(false, false) => "unknown key source".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
|
||||
os=$(uname -s 2>/dev/null || echo unknown)
|
||||
|
||||
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
|
||||
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
|
||||
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
|
||||
|
||||
read cpu_total_1 cpu_idle_1 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read cpu_total_2 cpu_idle_2 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_delta=$((cpu_total_2 - cpu_total_1))
|
||||
idle_delta=$((cpu_idle_2 - cpu_idle_1))
|
||||
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
|
||||
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$os" = "Darwin" ]; then
|
||||
net_stat() { netstat -ibn 2>/dev/null | awk '"'"'NR > 1 && $7 ~ /^[0-9]+$/ && $10 ~ /^[0-9]+$/ { rx += $7; tx += $10 } END { print rx+0, tx+0 }'"'"'; }
|
||||
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_percent=$(top -l 2 -n 0 -s 1 2>/dev/null | awk -F"[:,% ]+" '"'"'/CPU usage:/ { user=$3; sys=$5 } END { if (user == "" && sys == "") print "0.00"; else printf "%.2f", user + sys }'"'"')
|
||||
mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
|
||||
pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)
|
||||
vm_output=$(vm_stat 2>/dev/null)
|
||||
pages_active=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages active/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
pages_wired=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages wired down/ { gsub("\\.","",$4); print $4+0 }'"'"')
|
||||
pages_compressed=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages occupied by compressor/ { gsub("\\.","",$5); print $5+0 }'"'"')
|
||||
pages_speculative=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages speculative/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
mem_used=$(( (${pages_active:-0} + ${pages_wired:-0} + ${pages_compressed:-0} + ${pages_speculative:-0}) * ${pagesize:-4096} ))
|
||||
swap_line=$(sysctl vm.swapusage 2>/dev/null || true)
|
||||
swap_used=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ { value=$4; unit=substr(value, length(value), 1); sub(/[A-Za-z]+$/, "", value); printf "%.0f", value * mult(unit) }'"'"')
|
||||
swap_total=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ && /free/ { used=$4; free=$8; unit1=substr(used, length(used), 1); unit2=substr(free, length(free), 1); sub(/[A-Za-z]+$/, "", used); sub(/[A-Za-z]+$/, "", free); printf "%.0f", (used * mult(unit1)) + (free * mult(unit2)) }'"'"')
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=${mem_used:-0}"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=${swap_used:-0}"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(devfs|tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CPU_PERCENT=0.00"
|
||||
echo "MEM_TOTAL=0"
|
||||
echo "MEM_USED=0"
|
||||
echo "SWAP_TOTAL=0"
|
||||
echo "SWAP_USED=0"
|
||||
echo "NET_RX=0"
|
||||
echo "NET_TX=0"
|
||||
'"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for ClientHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
-211
@@ -1,211 +0,0 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
Key,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub auth: AuthMethod,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub private_key_path: String,
|
||||
#[serde(default)]
|
||||
pub private_key_inline: String,
|
||||
#[serde(default)]
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn password(host: String, port: u16, user: String, password: String) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Password,
|
||||
password,
|
||||
private_key_path: String::new(),
|
||||
private_key_inline: String::new(),
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
private_key_path: String,
|
||||
private_key_inline: String,
|
||||
) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Key,
|
||||
password: String::new(),
|
||||
private_key_path,
|
||||
private_key_inline,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ConfigFile {
|
||||
#[serde(default)]
|
||||
pub follow_system_theme: bool,
|
||||
#[serde(default)]
|
||||
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
|
||||
}
|
||||
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
cache: ConfigFile,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::config_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
|
||||
}
|
||||
|
||||
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()
|
||||
} else {
|
||||
ConfigFile::default()
|
||||
};
|
||||
|
||||
Ok(Self { path, cache })
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
cache: ConfigFile::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> Result<PathBuf> {
|
||||
let dirs = BaseDirs::new().context("could not determine user home directory")?;
|
||||
Ok(dirs
|
||||
.home_dir()
|
||||
.join(".config")
|
||||
.join("ashell")
|
||||
.join("sessions.json"))
|
||||
}
|
||||
|
||||
pub fn sessions(&self) -> &[Session] {
|
||||
&self.cache.sessions
|
||||
}
|
||||
|
||||
pub fn follow_system_theme(&self) -> bool {
|
||||
self.cache.follow_system_theme
|
||||
}
|
||||
|
||||
pub fn light_theme_name(&self) -> &str {
|
||||
&self.cache.light_theme_name
|
||||
}
|
||||
|
||||
pub fn dark_theme_name(&self) -> &str {
|
||||
&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()
|
||||
} else {
|
||||
self.cache.terminal_font_size
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_theme_preferences(
|
||||
&mut self,
|
||||
follow_system_theme: bool,
|
||||
light_theme_name: impl Into<String>,
|
||||
dark_theme_name: impl Into<String>,
|
||||
) {
|
||||
self.cache.follow_system_theme = follow_system_theme;
|
||||
self.cache.light_theme_name = light_theme_name.into();
|
||||
self.cache.dark_theme_name = dark_theme_name.into();
|
||||
}
|
||||
|
||||
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
|
||||
self.cache.terminal_font_size = terminal_font_size.max(10.0);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Session> {
|
||||
self.cache.sessions.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, session: Session) {
|
||||
if let Some(existing) = self.cache.sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
*existing = session;
|
||||
} else {
|
||||
self.cache.sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &str) {
|
||||
self.cache.sessions.retain(|s| s.id != id);
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&self.cache)?;
|
||||
fs::write(&self.path, raw).with_context(|| format!("failed to write {}", self.path.display()))
|
||||
}
|
||||
}
|
||||
+37
-3569
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,665 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
Key,
|
||||
KeyboardInteractive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub auth: AuthMethod,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub private_key_path: String,
|
||||
#[serde(default)]
|
||||
pub private_key_inline: String,
|
||||
#[serde(default)]
|
||||
pub passphrase: String,
|
||||
#[serde(default)]
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn password(host: String, port: u16, user: String, password: String) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Password,
|
||||
password,
|
||||
private_key_path: String::new(),
|
||||
private_key_inline: String::new(),
|
||||
passphrase: String::new(),
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
private_key_path: String,
|
||||
private_key_inline: String,
|
||||
passphrase: String,
|
||||
) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Key,
|
||||
password: String::new(),
|
||||
private_key_path,
|
||||
private_key_inline,
|
||||
passphrase,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyboard_interactive(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::KeyboardInteractive,
|
||||
password: String::new(),
|
||||
private_key_path: String::new(),
|
||||
private_key_inline: String::new(),
|
||||
passphrase: String::new(),
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum SavedWindowBounds {
|
||||
Fullscreen {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
Maximized {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
Windowed {
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
height: f32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TitleBarStyle {
|
||||
Native,
|
||||
#[default]
|
||||
Integrated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigFile {
|
||||
#[serde(default = "default_follow_system_theme")]
|
||||
pub follow_system_theme: bool,
|
||||
#[serde(default)]
|
||||
pub theme_mode: String,
|
||||
#[serde(default)]
|
||||
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 = "default_ui_font_size")]
|
||||
pub ui_font_size: f32,
|
||||
#[serde(default)]
|
||||
pub right_click_copy_paste: bool,
|
||||
#[serde(default)]
|
||||
pub keyword_highlight: bool,
|
||||
#[serde(default = "default_ui_font_family")]
|
||||
pub ui_font_family: String,
|
||||
#[serde(default = "default_terminal_font_family")]
|
||||
pub terminal_font_family: String,
|
||||
#[serde(default)]
|
||||
pub title_bar_style: TitleBarStyle,
|
||||
#[serde(default)]
|
||||
pub sessions: Vec<Session>,
|
||||
#[serde(default)]
|
||||
pub window_bounds: Option<SavedWindowBounds>,
|
||||
#[serde(default)]
|
||||
pub workspace_panels: Option<Vec<f32>>,
|
||||
#[serde(default)]
|
||||
pub body_panels: Option<Vec<f32>>,
|
||||
#[serde(default)]
|
||||
pub transfers: Vec<crate::terminal::Transfer>,
|
||||
#[serde(default)]
|
||||
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>,
|
||||
#[serde(default)]
|
||||
pub sync_endpoint: String,
|
||||
#[serde(default)]
|
||||
pub sync_username: String,
|
||||
#[serde(default)]
|
||||
pub sync_etag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_device_id: String,
|
||||
#[serde(default)]
|
||||
pub sync_backend: String,
|
||||
#[serde(default)]
|
||||
pub sync_etag_backend: String,
|
||||
#[serde(default)]
|
||||
pub sync_s3_endpoint: String,
|
||||
#[serde(default = "default_s3_region")]
|
||||
pub sync_s3_region: String,
|
||||
#[serde(default)]
|
||||
pub sync_s3_bucket: String,
|
||||
#[serde(default = "default_s3_object_key")]
|
||||
pub sync_s3_object_key: String,
|
||||
}
|
||||
|
||||
fn default_monitoring_position() -> String {
|
||||
"Sidebar".to_string()
|
||||
}
|
||||
|
||||
fn default_s3_region() -> String {
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
fn default_s3_object_key() -> String {
|
||||
"ashell-sync.json".to_string()
|
||||
}
|
||||
|
||||
fn default_follow_system_theme() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_locale() -> String {
|
||||
"system".to_string()
|
||||
}
|
||||
|
||||
fn default_terminal_font_size() -> f32 {
|
||||
18.0
|
||||
}
|
||||
|
||||
fn default_ui_font_size() -> f32 {
|
||||
14.0
|
||||
}
|
||||
|
||||
pub fn default_ui_font_family() -> String {
|
||||
// ".SystemUIFont" is a GPUI sentinel that resolves to the platform system UI font.
|
||||
// This matches gpui-component's own Theme default.
|
||||
".SystemUIFont".to_string()
|
||||
}
|
||||
|
||||
fn default_terminal_font_family() -> String {
|
||||
"Maple Mono NF CN".to_string()
|
||||
}
|
||||
|
||||
impl Default for ConfigFile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
follow_system_theme: default_follow_system_theme(),
|
||||
theme_mode: String::new(),
|
||||
light_theme_name: String::new(),
|
||||
dark_theme_name: String::new(),
|
||||
locale: default_locale(),
|
||||
terminal_font_size: default_terminal_font_size(),
|
||||
ui_font_size: default_ui_font_size(),
|
||||
right_click_copy_paste: false,
|
||||
keyword_highlight: false,
|
||||
ui_font_family: default_ui_font_family(),
|
||||
terminal_font_family: default_terminal_font_family(),
|
||||
title_bar_style: TitleBarStyle::default(),
|
||||
sessions: Vec::new(),
|
||||
window_bounds: None,
|
||||
workspace_panels: None,
|
||||
body_panels: None,
|
||||
transfers: Vec::new(),
|
||||
show_hidden_files: false,
|
||||
monitoring_position: default_monitoring_position(),
|
||||
sidebar_collapsed: false,
|
||||
sftp_panel_minimized: false,
|
||||
key_bindings: std::collections::HashMap::new(),
|
||||
sync_endpoint: String::new(),
|
||||
sync_username: String::new(),
|
||||
sync_etag: None,
|
||||
sync_device_id: String::new(),
|
||||
sync_backend: String::new(),
|
||||
sync_etag_backend: String::new(),
|
||||
sync_s3_endpoint: String::new(),
|
||||
sync_s3_region: default_s3_region(),
|
||||
sync_s3_bucket: String::new(),
|
||||
sync_s3_object_key: default_s3_object_key(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
cache: ConfigFile,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::config_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(mut perms) = fs::metadata(parent).map(|m| m.permissions()) {
|
||||
perms.set_mode(0o700);
|
||||
let _ = fs::set_permissions(parent, perms);
|
||||
}
|
||||
}
|
||||
|
||||
let tmp_dir = parent.join("tmp");
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
let _ = fs::create_dir_all(&tmp_dir);
|
||||
}
|
||||
|
||||
let mut cache = if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
match serde_json::from_str::<ConfigFile>(&raw) {
|
||||
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()
|
||||
};
|
||||
|
||||
if cache.sync_device_id.is_empty() {
|
||||
cache.sync_device_id = Uuid::new_v4().to_string();
|
||||
}
|
||||
Ok(Self { path, cache })
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
let cache = ConfigFile {
|
||||
sync_device_id: Uuid::new_v4().to_string(),
|
||||
..ConfigFile::default()
|
||||
};
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> Result<PathBuf> {
|
||||
let dirs = BaseDirs::new().context("could not determine user home directory")?;
|
||||
Ok(dirs
|
||||
.home_dir()
|
||||
.join(".config")
|
||||
.join("ashell")
|
||||
.join("sessions.json"))
|
||||
}
|
||||
|
||||
pub fn sessions(&self) -> &[Session] {
|
||||
&self.cache.sessions
|
||||
}
|
||||
|
||||
pub fn replace_sessions(&mut self, sessions: Vec<Session>) {
|
||||
self.cache.sessions = sessions;
|
||||
}
|
||||
|
||||
pub fn sync_endpoint(&self) -> &str {
|
||||
&self.cache.sync_endpoint
|
||||
}
|
||||
|
||||
pub fn sync_username(&self) -> &str {
|
||||
&self.cache.sync_username
|
||||
}
|
||||
|
||||
pub fn sync_etag(&self) -> Option<&str> {
|
||||
(self.cache.sync_etag_backend == self.sync_backend())
|
||||
.then_some(self.cache.sync_etag.as_deref())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn sync_device_id(&self) -> &str {
|
||||
&self.cache.sync_device_id
|
||||
}
|
||||
|
||||
pub fn sync_backend(&self) -> &str {
|
||||
if self.cache.sync_backend == "s3" {
|
||||
"s3"
|
||||
} else {
|
||||
"webdav"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_sync_backend(&mut self, backend: &str) {
|
||||
self.cache.sync_backend = if backend == "s3" { "s3" } else { "webdav" }.to_string();
|
||||
}
|
||||
|
||||
pub fn sync_s3_endpoint(&self) -> &str {
|
||||
&self.cache.sync_s3_endpoint
|
||||
}
|
||||
|
||||
pub fn sync_s3_region(&self) -> &str {
|
||||
if self.cache.sync_s3_region.is_empty() { "us-east-1" } else { &self.cache.sync_s3_region }
|
||||
}
|
||||
|
||||
pub fn sync_s3_bucket(&self) -> &str {
|
||||
&self.cache.sync_s3_bucket
|
||||
}
|
||||
|
||||
pub fn sync_s3_object_key(&self) -> &str {
|
||||
if self.cache.sync_s3_object_key.is_empty() { "ashell-sync.json" } else { &self.cache.sync_s3_object_key }
|
||||
}
|
||||
|
||||
pub fn set_sync_connection(&mut self, endpoint: String, username: String) {
|
||||
self.cache.sync_endpoint = endpoint;
|
||||
self.cache.sync_username = username;
|
||||
}
|
||||
|
||||
pub fn set_sync_s3_connection(
|
||||
&mut self,
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
) {
|
||||
self.cache.sync_s3_endpoint = endpoint;
|
||||
self.cache.sync_s3_region = region;
|
||||
self.cache.sync_s3_bucket = bucket;
|
||||
self.cache.sync_s3_object_key = object_key;
|
||||
}
|
||||
|
||||
pub fn set_sync_etag(&mut self, etag: Option<String>) {
|
||||
self.cache.sync_etag = etag;
|
||||
self.cache.sync_etag_backend = self.sync_backend().to_string();
|
||||
}
|
||||
|
||||
pub fn tmp_dir(&self) -> Option<PathBuf> {
|
||||
self.path.parent().map(|p| p.join("tmp"))
|
||||
}
|
||||
|
||||
pub fn follow_system_theme(&self) -> bool {
|
||||
self.cache.follow_system_theme
|
||||
}
|
||||
|
||||
pub fn theme_mode(&self) -> &str {
|
||||
&self.cache.theme_mode
|
||||
}
|
||||
|
||||
pub fn light_theme_name(&self) -> &str {
|
||||
&self.cache.light_theme_name
|
||||
}
|
||||
|
||||
pub fn dark_theme_name(&self) -> &str {
|
||||
&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 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"
|
||||
} else {
|
||||
&self.cache.monitoring_position
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_monitoring_position(&mut self, pos: &str) {
|
||||
self.cache.monitoring_position = pos.to_string();
|
||||
}
|
||||
|
||||
pub fn terminal_font_size(&self) -> f32 {
|
||||
if self.cache.terminal_font_size <= 0.0 {
|
||||
default_terminal_font_size()
|
||||
} else {
|
||||
self.cache.terminal_font_size
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_theme_preferences(
|
||||
&mut self,
|
||||
follow_system_theme: bool,
|
||||
theme_mode: impl Into<String>,
|
||||
light_theme_name: impl Into<String>,
|
||||
dark_theme_name: impl Into<String>,
|
||||
) {
|
||||
self.cache.follow_system_theme = follow_system_theme;
|
||||
self.cache.theme_mode = theme_mode.into();
|
||||
self.cache.light_theme_name = light_theme_name.into();
|
||||
self.cache.dark_theme_name = dark_theme_name.into();
|
||||
}
|
||||
|
||||
pub fn window_bounds(&self) -> Option<&SavedWindowBounds> {
|
||||
self.cache.window_bounds.as_ref()
|
||||
}
|
||||
|
||||
pub fn workspace_panels(&self) -> Option<&Vec<f32>> {
|
||||
self.cache.workspace_panels.as_ref()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn body_panels(&self) -> Option<&Vec<f32>> {
|
||||
self.cache.body_panels.as_ref()
|
||||
}
|
||||
|
||||
pub fn transfers(&self) -> Vec<crate::terminal::Transfer> {
|
||||
self.cache.transfers.clone()
|
||||
}
|
||||
|
||||
pub fn set_transfers(&mut self, transfers: Vec<crate::terminal::Transfer>) {
|
||||
self.cache.transfers = transfers;
|
||||
if let Err(err) = self.save() {
|
||||
tracing::error!("failed to save config: {err:#}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_layout_state(
|
||||
&mut self,
|
||||
window_bounds: Option<SavedWindowBounds>,
|
||||
workspace_panels: Option<Vec<f32>>,
|
||||
body_panels: Option<Vec<f32>>,
|
||||
) {
|
||||
self.cache.window_bounds = window_bounds;
|
||||
self.cache.workspace_panels = workspace_panels;
|
||||
self.cache.body_panels = body_panels;
|
||||
}
|
||||
|
||||
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
|
||||
self.cache.terminal_font_size = terminal_font_size.max(10.0);
|
||||
}
|
||||
|
||||
pub fn ui_font_size(&self) -> f32 {
|
||||
if self.cache.ui_font_size <= 0.0 {
|
||||
default_ui_font_size()
|
||||
} else {
|
||||
self.cache.ui_font_size
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ui_font_size(&mut self, ui_font_size: f32) {
|
||||
self.cache.ui_font_size = ui_font_size.max(8.0);
|
||||
}
|
||||
|
||||
pub fn ui_font_family(&self) -> &str {
|
||||
if self.cache.ui_font_family.is_empty() {
|
||||
".SystemUIFont"
|
||||
} else {
|
||||
&self.cache.ui_font_family
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ui_font_family(&mut self, family: &str) {
|
||||
self.cache.ui_font_family = family.to_string();
|
||||
}
|
||||
|
||||
pub fn right_click_copy_paste(&self) -> bool {
|
||||
self.cache.right_click_copy_paste
|
||||
}
|
||||
|
||||
pub fn set_right_click_copy_paste(&mut self, val: bool) {
|
||||
self.cache.right_click_copy_paste = val;
|
||||
}
|
||||
|
||||
pub fn keyword_highlight(&self) -> bool {
|
||||
self.cache.keyword_highlight
|
||||
}
|
||||
|
||||
pub fn set_keyword_highlight(&mut self, val: bool) {
|
||||
self.cache.keyword_highlight = val;
|
||||
}
|
||||
|
||||
pub fn terminal_font_family(&self) -> &str {
|
||||
if self.cache.terminal_font_family.is_empty() {
|
||||
"Maple Mono NF CN"
|
||||
} else {
|
||||
&self.cache.terminal_font_family
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_terminal_font_family(&mut self, family: &str) {
|
||||
self.cache.terminal_font_family = family.to_string();
|
||||
}
|
||||
|
||||
pub fn title_bar_style(&self) -> TitleBarStyle {
|
||||
self.cache.title_bar_style
|
||||
}
|
||||
|
||||
pub fn set_title_bar_style(&mut self, style: TitleBarStyle) {
|
||||
self.cache.title_bar_style = style;
|
||||
}
|
||||
|
||||
pub fn show_hidden_files(&self) -> bool {
|
||||
self.cache.show_hidden_files
|
||||
}
|
||||
|
||||
pub fn set_show_hidden_files(&mut self, val: bool) {
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, session: Session) {
|
||||
if let Some(existing) = self.cache.sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
*existing = session;
|
||||
} else {
|
||||
self.cache.sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &str) {
|
||||
self.cache.sessions.retain(|s| s.id != id);
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&self.cache)?;
|
||||
fs::write(&self.path, raw)
|
||||
.with_context(|| format!("failed to write {}", self.path.display()))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(mut perms) = fs::metadata(&self.path).map(|m| m.permissions()) {
|
||||
perms.set_mode(0o600);
|
||||
let _ = fs::set_permissions(&self.path, perms);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+1369
File diff suppressed because it is too large
Load Diff
-917
@@ -1,917 +0,0 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use directories::BaseDirs;
|
||||
use flate2::read::GzDecoder;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
|
||||
Disconnect,
|
||||
};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
task::JoinHandle,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
use zip::read::ZipArchive;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteEntry {
|
||||
pub name: String,
|
||||
pub full_path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
pub modified: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreviewData {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SftpCommand {
|
||||
ListDir(String),
|
||||
Preview(String),
|
||||
Download { remote: String, local_dir: String },
|
||||
UploadPaths { locals: Vec<String>, remote_dir: String },
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct SftpHandle {
|
||||
pub commands: UnboundedSender<SftpCommand>,
|
||||
#[allow(dead_code)]
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Clone for SftpHandle {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
commands: self.commands.clone(),
|
||||
join: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SftpHandle {
|
||||
pub fn list_dir(&self, path: String) {
|
||||
let _ = self.commands.send(SftpCommand::ListDir(path));
|
||||
}
|
||||
|
||||
pub fn preview(&self, path: String) {
|
||||
let _ = self.commands.send(SftpCommand::Preview(path));
|
||||
}
|
||||
|
||||
pub fn download(&self, remote: String, local_dir: String) {
|
||||
let _ = self.commands.send(SftpCommand::Download { remote, local_dir });
|
||||
}
|
||||
|
||||
pub fn upload_paths(&self, locals: Vec<String>, remote_dir: String) {
|
||||
let _ = self.commands.send(SftpCommand::UploadPaths { locals, remote_dir });
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let _ = self.commands.send(SftpCommand::Close);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_sftp(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> SftpHandle {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let join = runtime.spawn(async move {
|
||||
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, events.clone()).await {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id,
|
||||
text: format!("sftp error: {err:#}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
SftpHandle {
|
||||
commands: cmd_tx,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_sftp(
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
mut commands: UnboundedReceiver<SftpCommand>,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<()> {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: "sftp connecting...".into(),
|
||||
});
|
||||
|
||||
let handle = connect_and_authenticate(&session).await?;
|
||||
let channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open sftp channel")?;
|
||||
channel
|
||||
.request_subsystem(true, "sftp")
|
||||
.await
|
||||
.context("request sftp subsystem")?;
|
||||
let sftp = SftpSession::new(channel.into_stream())
|
||||
.await
|
||||
.context("sftp handshake")?;
|
||||
|
||||
let home = sftp.canonicalize(".").await.unwrap_or_else(|_| "/".to_string());
|
||||
emit_entries(&events, &tab_id, &sftp, &home).await?;
|
||||
|
||||
while let Some(command) = commands.recv().await {
|
||||
match command {
|
||||
SftpCommand::Close => break,
|
||||
SftpCommand::ListDir(path) => {
|
||||
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:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
SftpCommand::Preview(path) => match preview_impl(&sftp, &path).await {
|
||||
Ok(preview) => {
|
||||
let _ = events.send(BackendEvent::SftpPreview {
|
||||
tab_id: tab_id.clone(),
|
||||
preview,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("preview failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
},
|
||||
SftpCommand::Download { remote, local_dir } => {
|
||||
let base = base_name(&remote);
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("downloading {base}..."),
|
||||
});
|
||||
match download_path_impl(&handle, &sftp, &remote, Path::new(&local_dir)).await {
|
||||
Ok(summary) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: summary,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("download failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
SftpCommand::UploadPaths { locals, remote_dir } => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: "uploading...".into(),
|
||||
});
|
||||
match upload_paths_impl(&sftp, &locals, &remote_dir).await {
|
||||
Ok(summary) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: summary,
|
||||
});
|
||||
let _ = emit_entries(&events, &tab_id, &sftp, &remote_dir).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("upload failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle.disconnect(Disconnect::ByApplication, "bye", "").await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_entries(
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
tab_id: &str,
|
||||
sftp: &SftpSession,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
let entries = list_dir_impl(sftp, path).await?;
|
||||
let _ = events.send(BackendEvent::SftpEntries {
|
||||
tab_id: tab_id.to_string(),
|
||||
path: path.to_string(),
|
||||
entries,
|
||||
});
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: path.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_and_authenticate(session: &Session) -> Result<russh::client::Handle<SftpClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
let mut handle = client::connect(config, addr.as_str(), SftpClientHandler)
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => handle
|
||||
.authenticate_password(&session.user, &session.password)
|
||||
.await
|
||||
.context("password authentication failed")?,
|
||||
AuthMethod::Key => {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.context("public key authentication failed")?
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
return Err(anyhow!(
|
||||
"authentication failed: server rejected {} authentication for {}@{}:{}",
|
||||
match session.auth {
|
||||
AuthMethod::Password => "password",
|
||||
AuthMethod::Key => "public key",
|
||||
},
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
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 has_inline = !inline_key.is_empty();
|
||||
let has_path = key_path.is_some();
|
||||
|
||||
if !has_inline && !has_path {
|
||||
return Err(anyhow!("private key content or path is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if has_inline {
|
||||
match decode_secret_key(&inline_key, None) {
|
||||
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) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value == "~" {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("~/") {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
|
||||
}
|
||||
Some(Path::new(value).to_path_buf())
|
||||
}
|
||||
|
||||
fn base_name(path: &str) -> String {
|
||||
let sep = |c: char| c == '/' || c == '\\';
|
||||
path.trim_end_matches(sep)
|
||||
.rsplit(sep)
|
||||
.next()
|
||||
.unwrap_or(path)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn join_remote(parent: &str, child: &str) -> String {
|
||||
if parent == "/" {
|
||||
format!("/{child}")
|
||||
} else {
|
||||
format!("{}/{}", parent.trim_end_matches('/'), child)
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_archive_suffix(name: &str) -> &str {
|
||||
for suffix in [".tar.gz", ".tgz", ".zip", ".tar"] {
|
||||
if let Some(stripped) = name.strip_suffix(suffix) {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else if bytes < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_mtime(ts: u32) -> String {
|
||||
let dt: DateTime<Utc> = Utc
|
||||
.timestamp_opt(ts as i64, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now);
|
||||
dt.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
||||
async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result<Vec<RemoteEntry>> {
|
||||
let raw = sftp
|
||||
.read_dir(path)
|
||||
.await
|
||||
.with_context(|| format!("read_dir {path} failed"))?;
|
||||
|
||||
let mut entries = raw
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
let name = entry.file_name();
|
||||
name != "." && name != ".."
|
||||
})
|
||||
.map(|entry| {
|
||||
let name = entry.file_name().to_string();
|
||||
let full_path = join_remote(path, &name);
|
||||
let meta = entry.metadata();
|
||||
let permissions = meta.permissions.unwrap_or(0);
|
||||
let is_dir = (permissions & 0o170_000) == 0o040_000;
|
||||
let size = meta.size.unwrap_or(0);
|
||||
let modified = meta.mtime.unwrap_or(0);
|
||||
RemoteEntry {
|
||||
name,
|
||||
full_path,
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn preview_impl(sftp: &SftpSession, path: &str) -> Result<PreviewData> {
|
||||
let metadata = sftp.metadata(path).await.with_context(|| format!("metadata {path}"))?;
|
||||
let is_dir = metadata
|
||||
.permissions
|
||||
.map(|mode| (mode & 0o170_000) == 0o040_000)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_dir {
|
||||
let entries = list_dir_impl(sftp, path).await?;
|
||||
let mut lines = vec![format!("Directory: {path}"), String::new()];
|
||||
for entry in entries.into_iter().take(200) {
|
||||
let kind = if entry.is_dir { "dir " } else { "file" };
|
||||
lines.push(format!("{kind} {}", entry.name));
|
||||
}
|
||||
return Ok(PreviewData {
|
||||
path: path.to_string(),
|
||||
title: base_name(path),
|
||||
body: lines.join("\n"),
|
||||
is_binary: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut remote_file = sftp.open(path).await.with_context(|| format!("open remote {path}"))?;
|
||||
let mut buffer = vec![0u8; 128 * 1024];
|
||||
let read = remote_file.read(&mut buffer).await.context("read preview bytes")?;
|
||||
buffer.truncate(read);
|
||||
|
||||
let nul_ratio = if buffer.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
buffer.iter().filter(|byte| **byte == 0).count() as f32 / buffer.len() as f32
|
||||
};
|
||||
let is_binary = nul_ratio > 0.01;
|
||||
let body = if is_binary {
|
||||
format!(
|
||||
"Binary file\npath: {path}\nsize: {}\npreview: unavailable in-app",
|
||||
format_bytes(metadata.size.unwrap_or(0)),
|
||||
)
|
||||
} else {
|
||||
String::from_utf8_lossy(&buffer).into_owned()
|
||||
};
|
||||
|
||||
Ok(PreviewData {
|
||||
path: path.to_string(),
|
||||
title: base_name(path),
|
||||
body,
|
||||
is_binary,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_path_impl(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
sftp: &SftpSession,
|
||||
remote: &str,
|
||||
local_dir: &Path,
|
||||
) -> Result<String> {
|
||||
tokio::fs::create_dir_all(local_dir)
|
||||
.await
|
||||
.with_context(|| format!("create {}", local_dir.display()))?;
|
||||
|
||||
let metadata = sftp.metadata(remote).await.with_context(|| format!("metadata {remote}"))?;
|
||||
let is_dir = metadata
|
||||
.permissions
|
||||
.map(|mode| (mode & 0o170_000) == 0o040_000)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_dir {
|
||||
let local_archive = local_dir.join(format!(
|
||||
".ashell-{}-{}.tar.gz",
|
||||
base_name(remote),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let extracted_to = download_remote_directory_archive(handle, sftp, remote, &local_archive).await?;
|
||||
return Ok(format!("downloaded folder to {}", extracted_to.display()));
|
||||
}
|
||||
|
||||
let local_path = local_dir.join(base_name(remote));
|
||||
download_file_impl(sftp, remote, &local_path).await?;
|
||||
Ok(format!("downloaded file to {}", local_path.display()))
|
||||
}
|
||||
|
||||
async fn download_dir_recursive(sftp: &SftpSession, remote_dir: &str, local_dir: &Path) -> Result<()> {
|
||||
tokio::fs::create_dir_all(local_dir)
|
||||
.await
|
||||
.with_context(|| format!("create {}", local_dir.display()))?;
|
||||
let entries = list_dir_impl(sftp, remote_dir).await?;
|
||||
for entry in entries {
|
||||
let local_path = local_dir.join(&entry.name);
|
||||
if entry.is_dir {
|
||||
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path)).await?;
|
||||
} else {
|
||||
download_file_impl(sftp, &entry.full_path, &local_path).await?;
|
||||
let _ = maybe_extract_archive(&local_path).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_remote_directory_archive(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
sftp: &SftpSession,
|
||||
remote_dir: &str,
|
||||
local_archive: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let remote_archive = format!("/tmp/ashell-{}-{}.tar.gz", base_name(remote_dir), Uuid::new_v4());
|
||||
create_remote_archive(handle, remote_dir, &remote_archive).await?;
|
||||
let local_extract_root = local_archive
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(base_name(remote_dir));
|
||||
|
||||
let archive_download = async {
|
||||
download_file_impl(sftp, &remote_archive, local_archive).await?;
|
||||
extract_archive_to(local_archive, local_archive.parent().unwrap_or_else(|| Path::new("."))).await?;
|
||||
tokio::fs::remove_file(local_archive)
|
||||
.await
|
||||
.with_context(|| format!("remove {}", local_archive.display()))?;
|
||||
Ok::<PathBuf, anyhow::Error>(local_extract_root)
|
||||
}
|
||||
.await;
|
||||
|
||||
let cleanup_result = remove_remote_path(handle, &remote_archive).await;
|
||||
|
||||
let extracted_to = archive_download?;
|
||||
if let Err(err) = cleanup_result {
|
||||
tracing::warn!("failed to clean remote archive {remote_archive}: {err:#}");
|
||||
}
|
||||
|
||||
Ok(extracted_to)
|
||||
}
|
||||
|
||||
async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> Result<()> {
|
||||
let mut remote_file = sftp.open(remote).await.with_context(|| format!("open remote {remote}"))?;
|
||||
let mut local_file = tokio::fs::File::create(local)
|
||||
.await
|
||||
.with_context(|| format!("create local {}", local.display()))?;
|
||||
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = remote_file.read(&mut buffer).await.context("read remote file")?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
local_file
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write {}", local.display()))?;
|
||||
}
|
||||
local_file.flush().await.context("flush local file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_paths_impl(sftp: &SftpSession, locals: &[String], remote_dir: &str) -> Result<String> {
|
||||
create_remote_dir_all(sftp, remote_dir).await?;
|
||||
let mut file_count = 0usize;
|
||||
let mut folder_count = 0usize;
|
||||
for local in locals {
|
||||
let path = PathBuf::from(local);
|
||||
if path.is_dir() {
|
||||
upload_directory_impl(sftp, &path, remote_dir).await?;
|
||||
folder_count += 1;
|
||||
} else {
|
||||
upload_file_to_dir_impl(sftp, &path, remote_dir).await?;
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
let summary = match (file_count, folder_count) {
|
||||
(1, 0) => "uploaded file".to_string(),
|
||||
(0, 1) => "uploaded folder".to_string(),
|
||||
(files, 0) => format!("uploaded {files} files"),
|
||||
(0, folders) => format!("uploaded {folders} folders"),
|
||||
(files, folders) => format!("uploaded {files} files and {folders} folders"),
|
||||
};
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn upload_directory_impl(sftp: &SftpSession, local_dir: &Path, remote_parent: &str) -> Result<()> {
|
||||
let root_name = local_dir
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("invalid folder name: {}", local_dir.display()))?;
|
||||
let remote_root = join_remote(remote_parent, root_name);
|
||||
create_remote_dir_all(sftp, &remote_root).await?;
|
||||
|
||||
for entry in WalkDir::new(local_dir) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path == local_dir {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(local_dir)?;
|
||||
let remote_path = if relative.as_os_str().is_empty() {
|
||||
remote_root.clone()
|
||||
} else {
|
||||
let rel = relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
join_remote(&remote_root, &rel)
|
||||
};
|
||||
if path.is_dir() {
|
||||
create_remote_dir_all(sftp, &remote_path).await?;
|
||||
} else {
|
||||
if let Some(parent) = Path::new(&remote_path).parent() {
|
||||
let parent_remote = parent.to_string_lossy().replace('\\', "/");
|
||||
create_remote_dir_all(sftp, &parent_remote).await?;
|
||||
}
|
||||
upload_file_impl(sftp, path, &remote_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_to_dir_impl(sftp: &SftpSession, local_file: &Path, remote_dir: &str) -> Result<()> {
|
||||
let file_name = local_file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("invalid file name: {}", local_file.display()))?;
|
||||
let remote_path = join_remote(remote_dir, file_name);
|
||||
upload_file_impl(sftp, local_file, &remote_path).await
|
||||
}
|
||||
|
||||
async fn upload_file_impl(sftp: &SftpSession, local_file: &Path, remote_path: &str) -> Result<()> {
|
||||
let mut local = tokio::fs::File::open(local_file)
|
||||
.await
|
||||
.with_context(|| format!("open local {}", local_file.display()))?;
|
||||
let mut remote = sftp
|
||||
.create(remote_path)
|
||||
.await
|
||||
.with_context(|| format!("create remote {remote_path}"))?;
|
||||
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = local.read(&mut buffer).await.context("read local file")?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
remote
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write remote {remote_path}"))?;
|
||||
}
|
||||
remote.flush().await.context("flush remote file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_remote_dir_all(sftp: &SftpSession, remote_dir: &str) -> Result<()> {
|
||||
if remote_dir.is_empty() || remote_dir == "/" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut current = String::from("/");
|
||||
for segment in remote_dir.split('/').filter(|segment| !segment.is_empty()) {
|
||||
current = join_remote(¤t, segment);
|
||||
let _ = sftp.create_dir(¤t).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_remote_archive(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
remote_dir: &str,
|
||||
remote_archive: &str,
|
||||
) -> Result<()> {
|
||||
let remote_dir = remote_dir.trim_end_matches('/');
|
||||
let parent = remote_parent(remote_dir);
|
||||
let name = base_name(remote_dir);
|
||||
let command = format!(
|
||||
"tar -C {} -czf {} {}",
|
||||
shell_quote(&parent),
|
||||
shell_quote(remote_archive),
|
||||
shell_quote(&name),
|
||||
);
|
||||
exec_remote_command(handle, &command)
|
||||
.await
|
||||
.with_context(|| format!("archive remote directory {remote_dir}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_remote_path(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
remote_path: &str,
|
||||
) -> Result<()> {
|
||||
let command = format!("rm -f {}", shell_quote(remote_path));
|
||||
exec_remote_command(handle, &command)
|
||||
.await
|
||||
.with_context(|| format!("remove remote temporary file {remote_path}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_remote_command(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
command: &str,
|
||||
) -> Result<()> {
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open remote exec session")?;
|
||||
channel
|
||||
.exec(true, command)
|
||||
.await
|
||||
.with_context(|| format!("exec remote command: {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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match exit_status.unwrap_or(0) {
|
||||
0 => Ok(()),
|
||||
code => {
|
||||
let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&stdout).trim().to_string();
|
||||
Err(anyhow!(
|
||||
"remote command exited with {code}: {}",
|
||||
if !stderr.is_empty() { stderr } else { stdout }
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_parent(path: &str) -> String {
|
||||
if path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path.rsplit_once('/')
|
||||
.map(|(parent, _)| {
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
async fn maybe_extract_archive(path: &Path) -> Result<Option<PathBuf>> {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let is_archive = [".zip", ".tar", ".tar.gz", ".tgz"]
|
||||
.iter()
|
||||
.any(|suffix| file_name.ends_with(suffix));
|
||||
if !is_archive {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let extract_root = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(strip_archive_suffix(&file_name));
|
||||
let archive_path = path.to_path_buf();
|
||||
let target_dir = extract_root.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
fs::create_dir_all(&target_dir)
|
||||
.with_context(|| format!("create {}", target_dir.display()))?;
|
||||
|
||||
if file_name.ends_with(".zip") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut zip = ZipArchive::new(file).context("read zip archive")?;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).context("read zip entry")?;
|
||||
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output = target_dir.join(name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output)?;
|
||||
} else {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output_file = fs::File::create(&output)?;
|
||||
std::io::copy(&mut entry, &mut output_file)?;
|
||||
}
|
||||
}
|
||||
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
|
||||
} else if file_name.ends_with(".tar") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&target_dir).context("unpack tar archive")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("extract archive task join failure")??;
|
||||
|
||||
Ok(Some(extract_root))
|
||||
}
|
||||
|
||||
async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let archive_path = path.to_path_buf();
|
||||
let target_dir = target_dir.to_path_buf();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
fs::create_dir_all(&target_dir)
|
||||
.with_context(|| format!("create {}", target_dir.display()))?;
|
||||
|
||||
if file_name.ends_with(".zip") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut zip = ZipArchive::new(file).context("read zip archive")?;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).context("read zip entry")?;
|
||||
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output = target_dir.join(name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output)?;
|
||||
} else {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output_file = fs::File::create(&output)?;
|
||||
std::io::copy(&mut entry, &mut output_file)?;
|
||||
}
|
||||
}
|
||||
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
|
||||
} else if file_name.ends_with(".tar") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&target_dir).context("unpack tar archive")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("extract archive task join failure")??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SftpClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for SftpClientHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
+1842
File diff suppressed because it is too large
Load Diff
+395
@@ -0,0 +1,395 @@
|
||||
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
|
||||
|
||||
use crate::{
|
||||
Ashell, SftpContextMenuState,
|
||||
sftp::{RemoteEntry, SftpHandle},
|
||||
terminal,
|
||||
};
|
||||
|
||||
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",
|
||||
];
|
||||
if known_exts.contains(&ext) {
|
||||
return true;
|
||||
}
|
||||
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
|
||||
if known_names.contains(&lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
||||
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)
|
||||
.and_then(|g| g.sftp.as_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|id| self.sftp_handles.get(id))
|
||||
}
|
||||
|
||||
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
tracing::info!("[sftp] navigating to directory: '{}'", path);
|
||||
handle.list_dir(path.clone());
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.current_path = path;
|
||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
|
||||
if entry.is_dir {
|
||||
self.navigate_sftp(entry.full_path, cx);
|
||||
return;
|
||||
}
|
||||
self.mark_sftp_entry_selected(&entry.full_path, cx);
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if !sftp.selected_entries.remove(&entry.full_path) {
|
||||
sftp.selected_entries.insert(entry.full_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_sftp_entry_selected(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.selected_path = Some(path.to_string());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_parent_path(path: &str) -> String {
|
||||
if path == "/" {
|
||||
return "/".to_string();
|
||||
}
|
||||
path.trim_end_matches('/')
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| {
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_sftp(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) {
|
||||
self.navigate_sftp(path, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_sftp_path_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(path) = self.pending_sftp_path_sync.take() else {
|
||||
return;
|
||||
};
|
||||
self.sftp_path_input.update(cx, |state, cx| {
|
||||
state.set_value(path, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_sftp_context_menu(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
is_dir: bool,
|
||||
position: Point<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.sftp_context_menu = Some(SftpContextMenuState {
|
||||
remote_path,
|
||||
is_dir,
|
||||
position,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
|
||||
if self.sftp_context_menu.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
self.download_sftp_entry(menu.remote_path, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path);
|
||||
handle.edit_file(menu.remote_path);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn download_sftp_entry(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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| {
|
||||
match path_prompt.await {
|
||||
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
|
||||
);
|
||||
handle.download(remote_path, local_path);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("download picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("Select File to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
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
|
||||
);
|
||||
handle.upload_paths(vec![local_path], remote_dir);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Folder to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
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
|
||||
);
|
||||
handle.upload_paths(vec![local_path], remote_dir);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
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);
|
||||
} else {
|
||||
sftp.selected_entries.remove(&path);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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();
|
||||
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,
|
||||
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();
|
||||
}
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
|
||||
locals: paths,
|
||||
remote_dir: sftp.current_path.clone(),
|
||||
});
|
||||
self.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use directories::BaseDirs;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
|
||||
ChannelMsg, Disconnect,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
system::{remote_snapshot_from_kv, SystemSnapshot},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx},
|
||||
};
|
||||
|
||||
pub fn spawn_ssh_terminal(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> BackendTx {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
|
||||
let task_tab = tab_id.clone();
|
||||
runtime.spawn(async move {
|
||||
if let Err(err) = run_ssh(
|
||||
task_tab.clone(),
|
||||
session,
|
||||
cols,
|
||||
rows,
|
||||
cmd_rx,
|
||||
events.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: task_tab,
|
||||
reason: format!("{err:#}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
BackendTx::Ssh(cmd_tx)
|
||||
}
|
||||
|
||||
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
|
||||
let (events_tx, _events_rx) = std::sync::mpsc::channel();
|
||||
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open metrics session")?;
|
||||
channel
|
||||
.exec(true, REMOTE_SYSTEM_PROBE)
|
||||
.await
|
||||
.context("exec remote metrics probe")?;
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
|
||||
stdout.extend_from_slice(&data);
|
||||
}
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "metrics done", "")
|
||||
.await;
|
||||
let output = String::from_utf8_lossy(&stdout);
|
||||
remote_snapshot_from_kv(&output)
|
||||
}
|
||||
|
||||
async fn run_ssh(
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<()> {
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!(
|
||||
"connecting {}@{}:{}...",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
});
|
||||
|
||||
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
|
||||
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open session")?;
|
||||
channel
|
||||
.request_pty(true, "xterm-256color", cols.into(), rows.into(), 0, 0, &[])
|
||||
.await
|
||||
.context("request pty")?;
|
||||
channel.request_shell(true).await.context("request shell")?;
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("connected {}@{}", session.user, session.host),
|
||||
});
|
||||
let _ = events.send(BackendEvent::Connected {
|
||||
tab_id: tab_id.clone(),
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => {
|
||||
match command {
|
||||
Some(BackendCommand::Input(bytes)) => {
|
||||
if let Err(err) = channel.data(bytes.as_slice()).await {
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: tab_id.clone(),
|
||||
reason: format!("ssh write error: {err}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(BackendCommand::Resize { cols, rows }) => {
|
||||
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
|
||||
}
|
||||
Some(BackendCommand::Close) | None => {
|
||||
let _ = channel.eof().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = channel.wait() => {
|
||||
match msg {
|
||||
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
|
||||
let _ = events.send(BackendEvent::Output {
|
||||
tab_id: tab_id.clone(),
|
||||
bytes: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Some(ChannelMsg::Close) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "bye", "")
|
||||
.await;
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id,
|
||||
reason: "ssh session closed".into(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_and_authenticate(
|
||||
tab_id: &str,
|
||||
session: &Session,
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<russh::client::Handle<ClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("opening tcp connection to {addr}"),
|
||||
});
|
||||
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => {
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"connected to {addr}, sending password authentication for {}",
|
||||
session.user
|
||||
),
|
||||
});
|
||||
handle
|
||||
.authenticate_password(&session.user, &session.password)
|
||||
.await
|
||||
.context("password authentication failed")?
|
||||
}
|
||||
AuthMethod::Key => {
|
||||
let source = key_source_label(session);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
});
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user, session.host, session.port, source, algorithm
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
match session.auth {
|
||||
AuthMethod::Password => format!(
|
||||
"authentication failed: server rejected password authentication for {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
AuthMethod::Key => format!(
|
||||
"authentication failed: server rejected public key authentication for {}@{}:{} using {}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
key_source_label(session)
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"authentication accepted, opening shell for {}@{}",
|
||||
session.user, session.host
|
||||
),
|
||||
});
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
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 has_inline = !inline_key.is_empty();
|
||||
let has_path = key_path.is_some();
|
||||
|
||||
if !has_inline && !has_path {
|
||||
return Err(anyhow!("private key content or path is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if has_inline {
|
||||
match decode_secret_key(&inline_key, None) {
|
||||
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) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value == "~" {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("~/") {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
|
||||
}
|
||||
Some(Path::new(value).to_path_buf())
|
||||
}
|
||||
|
||||
fn key_source_label(session: &Session) -> String {
|
||||
let path = session.private_key_path.trim();
|
||||
let has_inline = !session.private_key_inline.trim().is_empty();
|
||||
match (!path.is_empty(), has_inline) {
|
||||
(true, true) => format!("inline key or {}", path),
|
||||
(true, false) => path.to_string(),
|
||||
(false, true) => "inline key text".to_string(),
|
||||
(false, false) => "unknown key source".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
|
||||
os=$(uname -s 2>/dev/null || echo unknown)
|
||||
|
||||
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
|
||||
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
|
||||
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
|
||||
|
||||
read cpu_total_1 cpu_idle_1 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read cpu_total_2 cpu_idle_2 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_delta=$((cpu_total_2 - cpu_total_1))
|
||||
idle_delta=$((cpu_idle_2 - cpu_idle_1))
|
||||
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
|
||||
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$os" = "Darwin" ]; then
|
||||
net_stat() { netstat -ibn 2>/dev/null | awk '"'"'NR > 1 && $7 ~ /^[0-9]+$/ && $10 ~ /^[0-9]+$/ { rx += $7; tx += $10 } END { print rx+0, tx+0 }'"'"'; }
|
||||
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_percent=$(top -l 2 -n 0 -s 1 2>/dev/null | awk -F"[:,% ]+" '"'"'/CPU usage:/ { user=$3; sys=$5 } END { if (user == "" && sys == "") print "0.00"; else printf "%.2f", user + sys }'"'"')
|
||||
mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
|
||||
pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)
|
||||
vm_output=$(vm_stat 2>/dev/null)
|
||||
pages_active=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages active/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
pages_wired=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages wired down/ { gsub("\\.","",$4); print $4+0 }'"'"')
|
||||
pages_compressed=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages occupied by compressor/ { gsub("\\.","",$5); print $5+0 }'"'"')
|
||||
pages_speculative=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages speculative/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
mem_used=$(( (${pages_active:-0} + ${pages_wired:-0} + ${pages_compressed:-0} + ${pages_speculative:-0}) * ${pagesize:-4096} ))
|
||||
swap_line=$(sysctl vm.swapusage 2>/dev/null || true)
|
||||
swap_used=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ { value=$4; unit=substr(value, length(value), 1); sub(/[A-Za-z]+$/, "", value); printf "%.0f", value * mult(unit) }'"'"')
|
||||
swap_total=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ && /free/ { used=$4; free=$8; unit1=substr(used, length(used), 1); unit2=substr(free, length(free), 1); sub(/[A-Za-z]+$/, "", used); sub(/[A-Za-z]+$/, "", free); printf "%.0f", (used * mult(unit1)) + (free * mult(unit2)) }'"'"')
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=${mem_used:-0}"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=${swap_used:-0}"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CPU_PERCENT=0.00"
|
||||
echo "MEM_TOTAL=0"
|
||||
echo "MEM_USED=0"
|
||||
echo "SWAP_TOTAL=0"
|
||||
echo "SWAP_USED=0"
|
||||
echo "NET_RX=0"
|
||||
echo "NET_TX=0"
|
||||
'"#;
|
||||
|
||||
struct ClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for ClientHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use argon2::Argon2;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chacha20poly1305::{
|
||||
XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit},
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use reqwest::{Client, StatusCode, header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::config::Session;
|
||||
|
||||
const SYNC_FILE_NAME: &str = "ashell-sync.json";
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncPayload {
|
||||
pub schema_version: u32,
|
||||
pub revision: String,
|
||||
pub updated_at: String,
|
||||
pub device_id: String,
|
||||
pub sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
impl SyncPayload {
|
||||
pub fn new(device_id: String, sessions: Vec<Session>) -> Self {
|
||||
Self {
|
||||
schema_version: FORMAT_VERSION,
|
||||
revision: Uuid::new_v4().to_string(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
device_id,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct EncryptedEnvelope {
|
||||
format_version: u32,
|
||||
kdf: String,
|
||||
cipher: String,
|
||||
salt: String,
|
||||
nonce: String,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncCredentials {
|
||||
pub backend: SyncBackendCredentials,
|
||||
pub encryption_password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SyncBackendCredentials {
|
||||
WebDav {
|
||||
endpoint: String,
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
S3 {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncResult {
|
||||
Uploaded {
|
||||
etag: Option<String>,
|
||||
},
|
||||
Downloaded {
|
||||
payload: SyncPayload,
|
||||
etag: Option<String>,
|
||||
},
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
credentials: SyncCredentials,
|
||||
payload: SyncPayload,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
validate_credentials(&credentials)?;
|
||||
let body = encrypt_payload(&payload, &credentials.encryption_password)?;
|
||||
match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => upload_webdav(&endpoint, &username, &password, body, expected_etag).await,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
upload_s3(&config, body, expected_etag).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let client = Client::new();
|
||||
let mut request = client
|
||||
.put(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
request = if let Some(etag) = expected_etag {
|
||||
request.header(header::IF_MATCH, etag)
|
||||
} else {
|
||||
// An uninitialized client may only create a new remote file. This keeps
|
||||
// it from silently replacing configuration uploaded by another device.
|
||||
request.header(header::IF_NONE_MATCH, "*")
|
||||
};
|
||||
let response = request.send().await.context("send WebDAV upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("WebDAV upload failed: HTTP {}", response.status()));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
pub async fn download(credentials: SyncCredentials) -> Result<(SyncPayload, Option<String>)> {
|
||||
validate_credentials(&credentials)?;
|
||||
let encryption_password = credentials.encryption_password;
|
||||
let (body, etag) = match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => download_webdav(&endpoint, &username, &password).await?,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
download_s3(&config).await?
|
||||
}
|
||||
};
|
||||
let payload = decrypt_payload(&body, &encryption_password)?;
|
||||
Ok((payload, etag))
|
||||
}
|
||||
|
||||
async fn download_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let response = Client::new()
|
||||
.get(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.send()
|
||||
.await
|
||||
.context("send WebDAV download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"WebDAV download failed: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("read WebDAV response")?
|
||||
.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn validate_credentials(credentials: &SyncCredentials) -> Result<()> {
|
||||
if credentials.encryption_password.len() < 8 {
|
||||
return Err(anyhow!(
|
||||
"encryption password must contain at least 8 characters"
|
||||
));
|
||||
}
|
||||
match &credentials.backend {
|
||||
SyncBackendCredentials::WebDav { endpoint, .. } if endpoint.trim().is_empty() => {
|
||||
Err(anyhow!("WebDAV endpoint is required"))
|
||||
}
|
||||
SyncBackendCredentials::S3 {
|
||||
region,
|
||||
bucket,
|
||||
access_key,
|
||||
secret_key,
|
||||
..
|
||||
} if region.trim().is_empty()
|
||||
|| bucket.trim().is_empty()
|
||||
|| access_key.trim().is_empty()
|
||||
|| secret_key.is_empty() =>
|
||||
{
|
||||
Err(anyhow!(
|
||||
"S3 region, bucket, access key and secret key are required"
|
||||
))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct S3Config {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
}
|
||||
|
||||
async fn upload_s3(
|
||||
config: &S3Config,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let url = s3_url(config)?;
|
||||
let mut headers = signed_s3_headers("PUT", &url, &body, config)?;
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if let Some(etag) = expected_etag {
|
||||
headers.insert(header::IF_MATCH, header_value(&etag, "S3 ETag")?);
|
||||
} else {
|
||||
headers.insert(header::IF_NONE_MATCH, header::HeaderValue::from_static("*"));
|
||||
}
|
||||
let response = Client::new()
|
||||
.put(url)
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 upload failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
async fn download_s3(config: &S3Config) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let url = s3_url(config)?;
|
||||
let headers = signed_s3_headers("GET", &url, &[], config)?;
|
||||
let response = Client::new()
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 download failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response.bytes().await.context("read S3 response")?.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn s3_url(config: &S3Config) -> Result<reqwest::Url> {
|
||||
let endpoint = if config.endpoint.trim().is_empty() {
|
||||
format!("https://s3.{}.amazonaws.com", config.region.trim())
|
||||
} else {
|
||||
config.endpoint.trim().trim_end_matches('/').to_string()
|
||||
};
|
||||
let key = if config.object_key.trim().is_empty() {
|
||||
SYNC_FILE_NAME
|
||||
} else {
|
||||
config.object_key.trim().trim_start_matches('/')
|
||||
};
|
||||
let url = format!(
|
||||
"{}/{}/{}",
|
||||
endpoint,
|
||||
aws_uri_encode(config.bucket.trim(), true),
|
||||
aws_uri_encode(key, false)
|
||||
);
|
||||
reqwest::Url::parse(&url).context("parse S3 object URL")
|
||||
}
|
||||
|
||||
fn signed_s3_headers(
|
||||
method: &str,
|
||||
url: &reqwest::Url,
|
||||
body: &[u8],
|
||||
config: &S3Config,
|
||||
) -> Result<header::HeaderMap> {
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date = now.format("%Y%m%d").to_string();
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("S3 endpoint has no host"))?;
|
||||
let host = match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
let payload_hash = hex_sha256(body);
|
||||
let mut canonical_headers =
|
||||
format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
|
||||
let mut signed_headers = "host;x-amz-content-sha256;x-amz-date".to_string();
|
||||
if !config.session_token.is_empty() {
|
||||
canonical_headers.push_str(&format!(
|
||||
"x-amz-security-token:{}\n",
|
||||
config.session_token.trim()
|
||||
));
|
||||
signed_headers.push_str(";x-amz-security-token");
|
||||
}
|
||||
let canonical_request = format!(
|
||||
"{method}\n{}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
|
||||
url.path()
|
||||
);
|
||||
let scope = format!("{date}/{}/s3/aws4_request", config.region.trim());
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
|
||||
hex_sha256(canonical_request.as_bytes())
|
||||
);
|
||||
let date_key = hmac_sha256(
|
||||
format!("AWS4{}", config.secret_key).as_bytes(),
|
||||
date.as_bytes(),
|
||||
)?;
|
||||
let region_key = hmac_sha256(&date_key, config.region.trim().as_bytes())?;
|
||||
let service_key = hmac_sha256(®ion_key, b"s3")?;
|
||||
let signing_key = hmac_sha256(&service_key, b"aws4_request")?;
|
||||
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?);
|
||||
let authorization = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
|
||||
config.access_key.trim()
|
||||
);
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert("x-amz-date", header_value(&amz_date, "S3 date")?);
|
||||
headers.insert(
|
||||
"x-amz-content-sha256",
|
||||
header_value(&payload_hash, "S3 payload hash")?,
|
||||
);
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
header_value(&authorization, "S3 authorization")?,
|
||||
);
|
||||
if !config.session_token.is_empty() {
|
||||
headers.insert(
|
||||
"x-amz-security-token",
|
||||
header_value(config.session_token.trim(), "S3 session token")?,
|
||||
);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn header_value(value: &str, name: &str) -> Result<header::HeaderValue> {
|
||||
header::HeaderValue::from_str(value).with_context(|| format!("invalid {name}"))
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], value: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut mac =
|
||||
<Hmac<Sha256> as Mac>::new_from_slice(key).map_err(|_| anyhow!("initialize S3 signer"))?;
|
||||
mac.update(value);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn hex_sha256(value: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(value))
|
||||
}
|
||||
|
||||
fn aws_uri_encode(value: &str, encode_slash: bool) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'-' | b'_' | b'.' | b'~')
|
||||
|| (!encode_slash && byte == b'/')
|
||||
{
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn sync_url(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.ends_with('/') {
|
||||
format!("{endpoint}{SYNC_FILE_NAME}")
|
||||
} else if endpoint.ends_with(".json") {
|
||||
endpoint.to_string()
|
||||
} else {
|
||||
format!("{endpoint}/{SYNC_FILE_NAME}")
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_payload(payload: &SyncPayload, password: &str) -> Result<Vec<u8>> {
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let key = derive_key(password, &salt)?;
|
||||
let plaintext = serde_json::to_vec(payload).context("serialize sync payload")?;
|
||||
let ciphertext = XChaCha20Poly1305::new((&key).into())
|
||||
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
|
||||
.map_err(|_| anyhow!("encrypt sync payload"))?;
|
||||
serde_json::to_vec_pretty(&EncryptedEnvelope {
|
||||
format_version: FORMAT_VERSION,
|
||||
kdf: "argon2id".to_string(),
|
||||
cipher: "xchacha20poly1305".to_string(),
|
||||
salt: STANDARD.encode(salt),
|
||||
nonce: STANDARD.encode(nonce),
|
||||
payload: STANDARD.encode(ciphertext),
|
||||
})
|
||||
.context("serialize encrypted sync envelope")
|
||||
}
|
||||
|
||||
fn decrypt_payload(raw: &[u8], password: &str) -> Result<SyncPayload> {
|
||||
let envelope: EncryptedEnvelope =
|
||||
serde_json::from_slice(raw).context("parse encrypted sync envelope")?;
|
||||
if envelope.format_version != FORMAT_VERSION
|
||||
|| envelope.kdf != "argon2id"
|
||||
|| envelope.cipher != "xchacha20poly1305"
|
||||
{
|
||||
return Err(anyhow!("unsupported remote sync format"));
|
||||
}
|
||||
let salt = STANDARD.decode(envelope.salt).context("decode sync salt")?;
|
||||
let nonce = STANDARD
|
||||
.decode(envelope.nonce)
|
||||
.context("decode sync nonce")?;
|
||||
if nonce.len() != 24 {
|
||||
return Err(anyhow!("invalid sync nonce"));
|
||||
}
|
||||
let ciphertext = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.context("decode encrypted sync payload")?;
|
||||
let key = derive_key(password, &salt)?;
|
||||
let plaintext = XChaCha20Poly1305::new((&key).into())
|
||||
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| anyhow!("cannot decrypt remote configuration; check the password"))?;
|
||||
let payload: SyncPayload =
|
||||
serde_json::from_slice(&plaintext).context("parse decrypted sync payload")?;
|
||||
if payload.schema_version != FORMAT_VERSION {
|
||||
return Err(anyhow!("unsupported synchronized configuration version"));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||
.map_err(|err| anyhow!("derive encryption key: {err}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypted_payload_round_trip() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(!String::from_utf8_lossy(&encrypted).contains("test-device"));
|
||||
let decrypted = decrypt_payload(&encrypted, "correct horse battery staple").unwrap();
|
||||
assert_eq!(decrypted.revision, payload.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_is_rejected() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(decrypt_payload(&encrypted, "incorrect password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_can_be_a_collection_or_file() {
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/dav/"),
|
||||
"https://example.test/dav/ashell-sync.json"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/config.json"),
|
||||
"https://example.test/config.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_url_uses_path_style_and_encodes_object_key() {
|
||||
let config = S3Config {
|
||||
endpoint: "https://s3.example.test".into(),
|
||||
region: "us-east-1".into(),
|
||||
bucket: "my-bucket".into(),
|
||||
object_key: "configs/my file.json".into(),
|
||||
access_key: "access".into(),
|
||||
secret_key: "secret".into(),
|
||||
session_token: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
s3_url(&config).unwrap().as_str(),
|
||||
"https://s3.example.test/my-bucket/configs/my%20file.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_uri_encoding_preserves_only_object_key_slashes() {
|
||||
assert_eq!(aws_uri_encode("a b/c", false), "a%20b/c");
|
||||
assert_eq!(aws_uri_encode("a/b", true), "a%2Fb");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
use std::{collections::BTreeMap, time::{Duration, Instant}};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::OsStr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{Result, anyhow};
|
||||
use sysinfo::{Disks, Networks, System};
|
||||
|
||||
/// Known virtual/ram filesystems to exclude from disk monitoring.
|
||||
fn is_real_filesystem(fs: &OsStr) -> bool {
|
||||
!matches!(
|
||||
fs.to_str(),
|
||||
Some("tmpfs" | "devtmpfs" | "ramfs" | "overlay" | "aufs")
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskSample {
|
||||
pub mount: String,
|
||||
@@ -19,7 +31,10 @@ pub struct SystemSnapshot {
|
||||
pub swap_detail: String,
|
||||
pub net_rx: String,
|
||||
pub net_tx: String,
|
||||
pub net_rx_rate: u64,
|
||||
pub net_tx_rate: u64,
|
||||
pub disks: Vec<DiskSample>,
|
||||
pub total_swap: u64,
|
||||
}
|
||||
|
||||
pub struct SystemSampler {
|
||||
@@ -69,42 +84,48 @@ impl SystemSampler {
|
||||
let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum();
|
||||
let tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum();
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_instant).as_secs_f64().max(0.001);
|
||||
let elapsed = now
|
||||
.duration_since(self.last_instant)
|
||||
.as_secs_f64()
|
||||
.max(0.001);
|
||||
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
|
||||
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
|
||||
self.last_rx_total = rx_total;
|
||||
self.last_tx_total = tx_total;
|
||||
self.last_instant = now;
|
||||
|
||||
let disks = self
|
||||
let mut disks: Vec<DiskSample> = self
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|disk| disk.total_space() > 0)
|
||||
.take(4)
|
||||
.filter(|disk| disk.total_space() > 0 && is_real_filesystem(disk.file_system()))
|
||||
.map(|disk| DiskSample {
|
||||
mount: disk.mount_point().to_string_lossy().to_string(),
|
||||
available_bytes: disk.available_space(),
|
||||
total_bytes: disk.total_space(),
|
||||
})
|
||||
.collect();
|
||||
disks.sort_by(|a, b| {
|
||||
if a.mount == "/" {
|
||||
return std::cmp::Ordering::Less;
|
||||
}
|
||||
if b.mount == "/" {
|
||||
return std::cmp::Ordering::Greater;
|
||||
}
|
||||
a.mount.cmp(&b.mount)
|
||||
});
|
||||
|
||||
SystemSnapshot {
|
||||
cpu_percent,
|
||||
mem_percent: ratio(mem_used, mem_total),
|
||||
swap_percent: ratio(swap_used, swap_total),
|
||||
mem_detail: format!(
|
||||
"{}/{}",
|
||||
format_bytes(mem_used),
|
||||
format_bytes(mem_total)
|
||||
),
|
||||
swap_detail: format!(
|
||||
"{}/{}",
|
||||
format_bytes(swap_used),
|
||||
format_bytes(swap_total)
|
||||
),
|
||||
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
|
||||
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
|
||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
||||
net_rx_rate: rx_rate,
|
||||
net_tx_rate: tx_rate,
|
||||
disks,
|
||||
total_swap: swap_total,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +199,20 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
let rx_rate = parse_u64(&kv, "NET_RX");
|
||||
let tx_rate = parse_u64(&kv, "NET_TX");
|
||||
|
||||
// Safety filter: exclude entries with zero/negligible total size
|
||||
// (catches any virtual fs lines that slipped past the script filter)
|
||||
disks.retain(|d| d.total_bytes >= 1024 * 1024);
|
||||
|
||||
disks.sort_by(|a, b| {
|
||||
if a.mount == "/" {
|
||||
return std::cmp::Ordering::Less;
|
||||
}
|
||||
if b.mount == "/" {
|
||||
return std::cmp::Ordering::Greater;
|
||||
}
|
||||
a.mount.cmp(&b.mount)
|
||||
});
|
||||
|
||||
Ok(SystemSnapshot {
|
||||
cpu_percent: cpu_percent.clamp(0.0, 1.0),
|
||||
mem_percent: ratio(mem_used, mem_total),
|
||||
@@ -186,7 +221,10 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
|
||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
||||
net_rx_rate: rx_rate,
|
||||
net_tx_rate: tx_rate,
|
||||
disks,
|
||||
total_swap: swap_total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,15 @@ use alacritty_terminal::{
|
||||
vte::ansi::{Color as AnsiColor, CursorShape, NamedColor},
|
||||
};
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, Hsla, InputHandler, IntoElement,
|
||||
LayoutId, Pixels, Point, Rgba, StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point, px,
|
||||
relative, rgb,
|
||||
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight,
|
||||
GlobalElementId, Hsla, InputHandler, IntoElement, LayoutId, Pixels, Point, Rgba, SharedString,
|
||||
StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point,
|
||||
px, relative, rgb,
|
||||
};
|
||||
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)]
|
||||
@@ -81,7 +83,13 @@ impl BatchedTextRun {
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window, cx: &mut App) {
|
||||
fn paint(
|
||||
&self,
|
||||
origin: Point<Pixels>,
|
||||
metrics: TerminalMetrics,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let pos = point(
|
||||
origin.x + metrics.cell_width * self.col as f32,
|
||||
origin.y + metrics.line_height * self.row as f32,
|
||||
@@ -120,10 +128,11 @@ pub struct TerminalElement {
|
||||
focus_handle: FocusHandle,
|
||||
snapshot: RenderSnapshot,
|
||||
marked_text: Option<String>,
|
||||
font_family: &'static str,
|
||||
font_family: SharedString,
|
||||
font_size: Pixels,
|
||||
line_height: Pixels,
|
||||
cell_width: Pixels,
|
||||
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
|
||||
}
|
||||
|
||||
pub struct PrepaintState {
|
||||
@@ -131,12 +140,24 @@ 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>,
|
||||
cell_width: f32,
|
||||
line_height: f32,
|
||||
}
|
||||
|
||||
impl InputHandler for TerminalInputHandler {
|
||||
@@ -146,10 +167,13 @@ impl InputHandler for TerminalInputHandler {
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<UTF16Selection> {
|
||||
self.view.read(cx).terminal_accepts_text_input().then_some(UTF16Selection {
|
||||
range: 0..0,
|
||||
reversed: false,
|
||||
})
|
||||
self.view
|
||||
.read(cx)
|
||||
.terminal_accepts_text_input()
|
||||
.then_some(UTF16Selection {
|
||||
range: 0..0,
|
||||
reversed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
@@ -207,9 +231,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.view.read(cx).terminal_ime_bounds_for_range(
|
||||
range_utf16,
|
||||
self.element_bounds,
|
||||
self.cell_width,
|
||||
self.line_height,
|
||||
)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
@@ -240,10 +267,11 @@ impl TerminalElement {
|
||||
focus_handle: FocusHandle,
|
||||
snapshot: RenderSnapshot,
|
||||
marked_text: Option<String>,
|
||||
font_family: &'static str,
|
||||
font_family: SharedString,
|
||||
font_size: Pixels,
|
||||
line_height: Pixels,
|
||||
cell_width: Pixels,
|
||||
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
view,
|
||||
@@ -254,13 +282,14 @@ impl TerminalElement {
|
||||
font_size,
|
||||
line_height,
|
||||
cell_width,
|
||||
search_highlights,
|
||||
}
|
||||
}
|
||||
|
||||
fn base_text_style(&self, cx: &App) -> TextStyle {
|
||||
TextStyle {
|
||||
color: cx.theme().foreground,
|
||||
font_family: self.font_family.into(),
|
||||
font_family: self.font_family.clone(),
|
||||
font_size: self.font_size.into(),
|
||||
line_height: self.line_height.into(),
|
||||
..Default::default()
|
||||
@@ -277,15 +306,21 @@ impl TerminalElement {
|
||||
fg.a *= 0.7;
|
||||
}
|
||||
|
||||
let underline = cell.flags.intersects(Flags::ALL_UNDERLINES).then(|| UnderlineStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||
});
|
||||
let strikethrough = cell.flags.contains(Flags::STRIKEOUT).then(|| StrikethroughStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
});
|
||||
let underline = cell
|
||||
.flags
|
||||
.intersects(Flags::ALL_UNDERLINES)
|
||||
.then(|| UnderlineStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||
});
|
||||
let strikethrough = cell
|
||||
.flags
|
||||
.contains(Flags::STRIKEOUT)
|
||||
.then(|| StrikethroughStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
});
|
||||
|
||||
let weight = if cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) {
|
||||
FontWeight::BOLD
|
||||
@@ -303,7 +338,7 @@ impl TerminalElement {
|
||||
color: fg,
|
||||
background_color: None,
|
||||
font: Font {
|
||||
family: self.font_family.into(),
|
||||
family: self.font_family.clone(),
|
||||
weight,
|
||||
style,
|
||||
..Font::default()
|
||||
@@ -313,11 +348,21 @@ 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;
|
||||
|
||||
// Retrieve cached keyword highlights and merge with search highlights
|
||||
let mut highlights = self.snapshot.highlights.clone();
|
||||
if let Some(sm) = self.search_highlights.as_ref() {
|
||||
highlights.extend(sm.iter().map(|(k, v)| (*k, *v)));
|
||||
}
|
||||
|
||||
for render_cell in &self.snapshot.cells {
|
||||
let cell = &render_cell.cell;
|
||||
if cell.flags.intersects(
|
||||
@@ -326,16 +371,19 @@ impl TerminalElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
let selected = self
|
||||
.snapshot
|
||||
.selection
|
||||
.is_some_and(|selection| selection_contains(selection, render_cell.row, render_cell.col));
|
||||
let selected = self.snapshot.selection.is_some_and(|selection| {
|
||||
selection_contains(selection, render_cell.row, render_cell.col)
|
||||
});
|
||||
let bg = color_to_hsla(cell.bg, false, cx);
|
||||
if selected || !is_default_bg(cell.bg) {
|
||||
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) {
|
||||
@@ -353,7 +401,34 @@ impl TerminalElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = self.cell_run_style(cell, cx);
|
||||
let mut style = self.cell_run_style(cell, cx);
|
||||
|
||||
// Apply keyword highlight color if this cell was matched.
|
||||
if let Some(&hl_color) = highlights.get(&(render_cell.row, render_cell.col)) {
|
||||
style.color = hl_color;
|
||||
}
|
||||
|
||||
// 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());
|
||||
@@ -385,7 +460,7 @@ impl TerminalElement {
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
(merge_rects(rects), runs)
|
||||
(merge_rects(rects), runs, custom_blocks)
|
||||
}
|
||||
|
||||
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
|
||||
@@ -441,7 +516,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 {
|
||||
@@ -450,6 +525,7 @@ impl Element for TerminalElement {
|
||||
},
|
||||
rects,
|
||||
runs,
|
||||
custom_blocks,
|
||||
cursor: self.cursor_layout(cx),
|
||||
}
|
||||
}
|
||||
@@ -472,11 +548,29 @@ 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 {
|
||||
view: self.view.clone(),
|
||||
element_bounds: prepaint.bounds,
|
||||
cell_width: prepaint.metrics.cell_width.as_f32(),
|
||||
line_height: prepaint.metrics.line_height.as_f32(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
@@ -499,7 +593,7 @@ impl Element for TerminalElement {
|
||||
&[TextRun {
|
||||
len: marked_text.len(),
|
||||
font: Font {
|
||||
family: self.font_family.into(),
|
||||
family: self.font_family.clone(),
|
||||
..Font::default()
|
||||
},
|
||||
color: base_style.color,
|
||||
@@ -525,7 +619,11 @@ impl Element for TerminalElement {
|
||||
}
|
||||
|
||||
if let Some(cursor) = prepaint.cursor {
|
||||
if self.marked_text.as_ref().is_some_and(|text| !text.is_empty()) {
|
||||
if self
|
||||
.marked_text
|
||||
.as_ref()
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
|
||||
@@ -534,7 +632,10 @@ impl Element for TerminalElement {
|
||||
CursorShape::Hidden => {}
|
||||
CursorShape::Beam => {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(point(x, y), gpui::size(px(2.), prepaint.metrics.line_height)),
|
||||
Bounds::new(
|
||||
point(x, y),
|
||||
gpui::size(px(2.), prepaint.metrics.line_height),
|
||||
),
|
||||
cursor.color,
|
||||
));
|
||||
}
|
||||
@@ -572,7 +673,10 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
|
||||
|
||||
for rect in rects {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.row == rect.row && last.color == rect.color && last.col + last.cells as i32 == rect.col {
|
||||
if last.row == rect.row
|
||||
&& last.color == rect.color
|
||||
&& last.col + last.cells as i32 == rect.col
|
||||
{
|
||||
last.cells += rect.cells;
|
||||
continue;
|
||||
}
|
||||
@@ -601,7 +705,11 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
|
||||
}
|
||||
|
||||
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
|
||||
cell.c == ' ' && cell.zerowidth().is_none() && !cell.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
|
||||
cell.c == ' '
|
||||
&& cell.zerowidth().is_none()
|
||||
&& !cell
|
||||
.flags
|
||||
.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
|
||||
}
|
||||
|
||||
fn is_default_bg(color: AnsiColor) -> bool {
|
||||
@@ -623,8 +731,8 @@ fn color_to_hsla(color: AnsiColor, foreground: bool, cx: &App) -> Hsla {
|
||||
|
||||
fn ansi_index_color(index: u8, _cx: &App) -> Hsla {
|
||||
const ANSI_16: [u32; 16] = [
|
||||
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0,
|
||||
0x686868, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
|
||||
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0, 0x686868,
|
||||
0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
|
||||
];
|
||||
|
||||
if (index as usize) < ANSI_16.len() {
|
||||
@@ -0,0 +1,358 @@
|
||||
use std::collections::HashMap;
|
||||
use gpui::Hsla;
|
||||
use crate::terminal::RenderCell;
|
||||
|
||||
trait HslaExt {
|
||||
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self;
|
||||
}
|
||||
|
||||
impl HslaExt for Hsla {
|
||||
fn into_rgba_like(self, r: u8, g: u8, b: u8) -> Self {
|
||||
let rf = r as f32 / 255.0;
|
||||
let gf = g as f32 / 255.0;
|
||||
let bf = b as f32 / 255.0;
|
||||
let max = rf.max(gf).max(bf);
|
||||
let min = rf.min(gf).min(bf);
|
||||
let l = (max + min) / 2.0;
|
||||
if max == min {
|
||||
return Hsla { h: 0.0, s: 0.0, l, a: 1.0 };
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == rf {
|
||||
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) / 6.0
|
||||
} else if max == gf {
|
||||
((bf - rf) / d + 2.0) / 6.0
|
||||
} else {
|
||||
((rf - gf) / d + 4.0) / 6.0
|
||||
};
|
||||
Hsla { h, s, l, a: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct HighlightColors {
|
||||
error: Hsla,
|
||||
success: Hsla,
|
||||
warning: Hsla,
|
||||
info: Hsla,
|
||||
failure: Hsla,
|
||||
network: Hsla,
|
||||
url: Hsla,
|
||||
port: Hsla,
|
||||
debug: Hsla,
|
||||
}
|
||||
|
||||
fn hsla(r: u8, g: u8, b: u8) -> Hsla {
|
||||
Hsla {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
l: 0.0,
|
||||
a: 1.0,
|
||||
}
|
||||
.into_rgba_like(r, g, b)
|
||||
}
|
||||
|
||||
fn highlight_colors() -> HighlightColors {
|
||||
HighlightColors {
|
||||
error: hsla(224, 96, 96), // #E06060 red
|
||||
success: hsla(126, 198, 153), // #7EC699 green
|
||||
warning: hsla(232, 201, 122), // #E8C97A yellow
|
||||
info: hsla(108, 180, 238), // #6CB4EE blue
|
||||
failure: hsla(232, 168, 124), // #E8A87C orange
|
||||
network: hsla(199, 146, 234), // #C792EA purple
|
||||
url: hsla( 86, 212, 199), // #56D4C7 teal
|
||||
port: hsla(130, 170, 200), // #82AAC8 muted teal
|
||||
debug: hsla(130, 140, 155), // #828C9B gray
|
||||
}
|
||||
}
|
||||
|
||||
fn is_boundary(c: char) -> bool {
|
||||
!c.is_ascii_alphanumeric() && c != '_'
|
||||
}
|
||||
|
||||
pub fn highlight_cells(
|
||||
cells: &[RenderCell],
|
||||
rows: usize,
|
||||
) -> HashMap<(i32, i32), Hsla> {
|
||||
let colors = highlight_colors();
|
||||
|
||||
// Pre-allocate the outer vector to the size of rows.
|
||||
let mut row_chars: Vec<Vec<(i32, char)>> = vec![Vec::with_capacity(128); rows];
|
||||
for rc in cells {
|
||||
if rc.row < 0 || (rc.row as usize) >= rows {
|
||||
continue;
|
||||
}
|
||||
row_chars[rc.row as usize].push((rc.col, rc.cell.c));
|
||||
}
|
||||
for row in row_chars.iter_mut() {
|
||||
row.sort_by_key(|&(col, _)| col);
|
||||
}
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
// Reusable buffers to avoid allocation inside the loop
|
||||
let mut chars_buf = String::with_capacity(128);
|
||||
let mut byte_to_col: Vec<i32> = Vec::with_capacity(128);
|
||||
|
||||
for (row_idx, row) in row_chars.iter().enumerate() {
|
||||
if row.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let row_i32 = row_idx as i32;
|
||||
|
||||
chars_buf.clear();
|
||||
byte_to_col.clear();
|
||||
|
||||
for &(col, c) in row {
|
||||
chars_buf.push(c);
|
||||
while byte_to_col.len() < chars_buf.len() {
|
||||
byte_to_col.push(col);
|
||||
}
|
||||
}
|
||||
let text = chars_buf.as_str();
|
||||
|
||||
// ── 1. Error keywords ──────────────────────────
|
||||
for kw in &["EMERGENCY", "CRITICAL", "FATAL", "PANIC", "ERROR", "ERR"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Success keywords ───────────────────────────
|
||||
for kw in &["SUCCESS", "SUCCEEDED", "PASSED", "PASS", "OK"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Failure keywords ───────────────────────────
|
||||
for kw in &["FAILED", "FAILURE", "DENIED", "REJECTED", "TIMEOUT", "FAIL"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Warning keywords ───────────────────────────
|
||||
for kw in &["WARNING", "WARN"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Info keywords ──────────────────────────────
|
||||
for kw in &["NOTICE", "INFO"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Debug keywords ─────────────────────────────
|
||||
for kw in &["DEBUG", "DBG", "TRACE"] {
|
||||
for m in find_keyword(text, kw) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + kw.len()).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.debug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 7. IP addresses ───────────────────────────────
|
||||
for m in find_ip_addresses(text) {
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + find_ip_len(&text[m..])).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.network);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 8. URLs ───────────────────────────────────────
|
||||
for m in find_urls(text) {
|
||||
let url_len = find_url_len(&text[m..]);
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + url_len).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.url);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 9. Port numbers ─────────────────────────────────────
|
||||
for m in find_ports(text) {
|
||||
let port_len = find_port_len(&text[m..]);
|
||||
let start_col = byte_to_col[m];
|
||||
let end_col = byte_to_col[(m + port_len).min(byte_to_col.len() - 1)];
|
||||
for c in start_col..=end_col {
|
||||
map.entry((row_i32, c)).or_insert(colors.port);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn find_keyword(text: &str, keyword: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let mut start = 0;
|
||||
while let Some(pos) = text[start..].find(keyword) {
|
||||
let abs = start + pos;
|
||||
let before_ok = abs == 0
|
||||
|| text.as_bytes()[abs - 1] == b' '
|
||||
|| is_boundary(text.as_bytes()[abs - 1] as char);
|
||||
let after_pos = abs + keyword.len();
|
||||
let after_ok = after_pos >= text.len()
|
||||
|| text.as_bytes()[after_pos] == b' '
|
||||
|| is_boundary(text.as_bytes()[after_pos] as char);
|
||||
if before_ok && after_ok {
|
||||
positions.push(abs);
|
||||
}
|
||||
start = abs + keyword.len();
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn find_ip_len(text: &str) -> usize {
|
||||
let bytes = text.as_bytes();
|
||||
let mut dots = 0u8;
|
||||
let mut digits = 0u8;
|
||||
let mut len = 0usize;
|
||||
|
||||
for &b in bytes {
|
||||
match b {
|
||||
b'0'..=b'9' => {
|
||||
digits += 1;
|
||||
if digits > 3 {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
b'.' => {
|
||||
if digits == 0 {
|
||||
return 0;
|
||||
}
|
||||
dots += 1;
|
||||
if dots > 3 {
|
||||
return 0;
|
||||
}
|
||||
digits = 0;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
len += 1;
|
||||
}
|
||||
|
||||
if dots == 3 && digits > 0 {
|
||||
len
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn find_ip_addresses(text: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let bytes = text.as_bytes();
|
||||
let len = bytes.len();
|
||||
|
||||
for i in 0..len {
|
||||
if bytes[i].is_ascii_digit()
|
||||
&& (i == 0 || is_boundary(bytes[i - 1] as char))
|
||||
{
|
||||
let remaining = &text[i..];
|
||||
let ip_len = find_ip_len(remaining);
|
||||
if ip_len > 0 {
|
||||
let ip_str = &remaining[..ip_len];
|
||||
let valid = ip_str
|
||||
.split('.')
|
||||
.all(|octet| octet.parse::<u8>().is_ok());
|
||||
if valid {
|
||||
positions.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn find_urls(text: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let mut start = 0;
|
||||
while let Some(pos) = text[start..].find("http") {
|
||||
let abs = start + pos;
|
||||
let remaining = &text[abs..];
|
||||
if remaining.starts_with("https://") || remaining.starts_with("http://") {
|
||||
if abs == 0 || is_boundary(text.as_bytes()[abs - 1] as char) {
|
||||
positions.push(abs);
|
||||
}
|
||||
}
|
||||
start = abs + 4;
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn find_url_len(text: &str) -> usize {
|
||||
text.find(|c: char| c.is_ascii_whitespace())
|
||||
.unwrap_or(text.len())
|
||||
}
|
||||
|
||||
fn find_ports(text: &str) -> Vec<usize> {
|
||||
let mut positions = Vec::new();
|
||||
let bytes = text.as_bytes();
|
||||
let len = bytes.len();
|
||||
|
||||
for i in 0..len {
|
||||
if bytes[i] == b':'
|
||||
&& i + 1 < len
|
||||
&& bytes[i + 1].is_ascii_digit()
|
||||
&& (i == 0 || is_boundary(bytes[i - 1] as char) || bytes[i - 1] == b' ')
|
||||
{
|
||||
let mut j = i + 1;
|
||||
while j < len && bytes[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
let port_str = &text[i + 1..j];
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
if port > 0 {
|
||||
let after_ok = j >= len || is_boundary(bytes[j] as char);
|
||||
if after_ok {
|
||||
positions.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn find_port_len(text: &str) -> usize {
|
||||
if !text.starts_with(':') {
|
||||
return 0;
|
||||
}
|
||||
let mut len = 1;
|
||||
for b in text.as_bytes()[1..].iter() {
|
||||
if b.is_ascii_digit() {
|
||||
len += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
len
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use alacritty_terminal::index::Side;
|
||||
use alacritty_terminal::selection::SelectionType;
|
||||
use gpui::{
|
||||
ClipboardItem, Context, Focusable as _, KeyDownEvent, MouseButton, MouseDownEvent,
|
||||
MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollDelta, ScrollWheelEvent, Window, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
Ashell, TerminalBacktabKey, TerminalTabKey,
|
||||
terminal::{BackendCommand, encode_key},
|
||||
};
|
||||
|
||||
thread_local! {
|
||||
static LAST_DRAG_SCROLL: std::cell::Cell<Option<std::time::Instant>> = std::cell::Cell::new(None);
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn on_terminal_key_down(
|
||||
&mut self,
|
||||
event: &KeyDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// If the search input is focused, skip terminal key processing
|
||||
// so the input can handle text entry, paste, etc. normally.
|
||||
if self
|
||||
.search_input
|
||||
.read(cx)
|
||||
.focus_handle(cx)
|
||||
.is_focused(window)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Pane navigation: Alt + h/j/k/l
|
||||
if event.keystroke.modifiers.alt
|
||||
&& !event.keystroke.modifiers.shift
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
match event.keystroke.key.to_ascii_lowercase().as_str() {
|
||||
"h" => self.focus_adjacent_pane("left"),
|
||||
"j" => self.focus_adjacent_pane("down"),
|
||||
"k" => self.focus_adjacent_pane("up"),
|
||||
"l" => self.focus_adjacent_pane("right"),
|
||||
"q" => {
|
||||
if let Some(active_id) = self.active_tab.clone() {
|
||||
self.close_tab(active_id, cx);
|
||||
}
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pane split: Shift+Alt + h/j/k/l
|
||||
if event.keystroke.modifiers.shift
|
||||
&& event.keystroke.modifiers.alt
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
let direction = match event.keystroke.key.to_ascii_lowercase().as_str() {
|
||||
"h" => Some("left"),
|
||||
"j" => Some("down"),
|
||||
"k" => Some("up"),
|
||||
"l" => Some("right"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(dir) = direction {
|
||||
self.split_current_pane(dir, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key == "," {
|
||||
self.show_settings_dialog(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
if event.keystroke.modifiers.shift
|
||||
&& event.keystroke.modifiers.secondary()
|
||||
&& event.keystroke.key == "o"
|
||||
{
|
||||
self.show_selector_dialog(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("c") {
|
||||
if let Some(text) = self.active_terminal_selection_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if event.keystroke.modifiers.secondary() && event.keystroke.key.eq_ignore_ascii_case("v") {
|
||||
if let Some(clipboard) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard.text() {
|
||||
self.paste_into_terminal(&text, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if event.prefer_character_input {
|
||||
if let Some(text) = event.keystroke.key_char.as_deref() {
|
||||
if !text.is_empty()
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.function
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
self.send_terminal_input(text.as_bytes().to_vec(), window, cx);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
|
||||
if let Some(bytes) = encode_key(&event.keystroke, tab.app_cursor_mode(), false) {
|
||||
tab.backend.send(BackendCommand::Input(bytes));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_tab_action(
|
||||
&mut self,
|
||||
_: &TerminalTabKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.send_terminal_input(vec![b'\t'], window, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_backtab_action(
|
||||
&mut self,
|
||||
_: &TerminalBacktabKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.send_terminal_input(b"\x1b[Z".to_vec(), window, cx);
|
||||
}
|
||||
|
||||
fn send_terminal_input(&mut self, bytes: Vec<u8>, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|t| t.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
|
||||
tab.clear_selection();
|
||||
tab.backend.send(BackendCommand::Input(bytes));
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn active_terminal_selection_text(&self) -> Option<String> {
|
||||
let active_id = self.active_tab.as_ref()?;
|
||||
self.tabs
|
||||
.iter()
|
||||
.find(|tab| &tab.id == active_id)
|
||||
.and_then(|tab| tab.selection_text())
|
||||
}
|
||||
|
||||
fn paste_into_terminal(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
tab.paste_text(text);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_accepts_text_input(&self) -> bool {
|
||||
self.active_tab.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_marked_text_range(&self) -> Option<Range<usize>> {
|
||||
self.terminal_marked_text
|
||||
.as_ref()
|
||||
.map(|text| 0..text.encode_utf16().count())
|
||||
}
|
||||
|
||||
pub(crate) fn set_terminal_marked_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.terminal_marked_text = if text.is_empty() { None } else { Some(text) };
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_terminal_marked_text(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.terminal_marked_text.take().is_some() {
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_terminal_ime_text(
|
||||
&mut self,
|
||||
text: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if tab.render_snapshot().display_offset > 0 {
|
||||
tab.scroll_to_bottom();
|
||||
}
|
||||
tab.clear_selection();
|
||||
self.terminal_marked_text = None;
|
||||
tab.backend
|
||||
.send(BackendCommand::Input(text.as_bytes().to_vec()));
|
||||
window.invalidate_character_coordinates();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_right_click(
|
||||
&mut self,
|
||||
_event: &MouseDownEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if !self.config.right_click_copy_paste() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut handled = false;
|
||||
if let Some(text) = self.active_terminal_selection_text() {
|
||||
if !text.is_empty() {
|
||||
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
|
||||
|
||||
let active_id = self.active_tab.clone();
|
||||
if let Some(active_id) = active_id {
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.clear_selection();
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !handled {
|
||||
if let Some(clipboard_item) = cx.read_from_clipboard() {
|
||||
if let Some(text) = clipboard_item.text() {
|
||||
if !text.is_empty() {
|
||||
self.paste_into_terminal(&text, window, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
2 => SelectionType::Semantic,
|
||||
3 => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
|
||||
return;
|
||||
};
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
tab.begin_selection(row, col, side, selection_type);
|
||||
self.terminal_selecting = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_mouse_move(
|
||||
&mut self,
|
||||
event: &MouseMoveEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
// Handle split drag
|
||||
if self.dragging_splitter.is_some() {
|
||||
if event.pressed_button == Some(MouseButton::Left) {
|
||||
self.on_split_drag_move(event, window, cx);
|
||||
cx.notify();
|
||||
} else {
|
||||
self.end_drag_split();
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !self.terminal_selecting || event.pressed_button != Some(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Some((row, col, side)) = self.terminal_grid_point_and_side(event.position) else {
|
||||
return;
|
||||
};
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
let snapshot = match self.active_snapshot() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
let max_row = snapshot.rows.saturating_sub(1);
|
||||
|
||||
let mut scroll_delta = 0i32;
|
||||
if max_row >= 6 {
|
||||
if row <= 2 || row >= max_row.saturating_sub(2) {
|
||||
let now = std::time::Instant::now();
|
||||
let should_scroll = LAST_DRAG_SCROLL.with(|last| {
|
||||
if let Some(last_time) = last.get() {
|
||||
if now.duration_since(last_time) >= std::time::Duration::from_millis(80) {
|
||||
last.set(Some(now));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
last.set(Some(now));
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
if should_scroll {
|
||||
if row == 0 {
|
||||
scroll_delta = 2;
|
||||
} else if row == 1 {
|
||||
scroll_delta = 1;
|
||||
} else if row == 2 {
|
||||
scroll_delta = 1;
|
||||
} else if row == max_row {
|
||||
scroll_delta = -2;
|
||||
} else if row == max_row.saturating_sub(1) {
|
||||
scroll_delta = -1;
|
||||
} else if row == max_row.saturating_sub(2) {
|
||||
scroll_delta = -1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LAST_DRAG_SCROLL.with(|last| last.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
if scroll_delta != 0 {
|
||||
tab.scroll_history(scroll_delta);
|
||||
}
|
||||
tab.update_selection(row, col, side);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_mouse_up(
|
||||
&mut self,
|
||||
_event: &MouseUpEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.dragging_splitter.is_some() {
|
||||
self.end_drag_split();
|
||||
}
|
||||
self.terminal_selecting = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn terminal_grid_point_and_side(
|
||||
&self,
|
||||
position: Point<Pixels>,
|
||||
) -> Option<(usize, usize, Side)> {
|
||||
let active_id = self.active_tab.as_ref()?;
|
||||
let bounds = self.terminal_bounds.get(active_id)?;
|
||||
if !bounds.contains(&position) {
|
||||
// Try other pane bounds
|
||||
for (_, b) in &self.terminal_bounds {
|
||||
if b.contains(&position) {
|
||||
// Found a different pane - focus it
|
||||
// (this path is for click-to-focus; handled via focus_terminal)
|
||||
return None;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let local_x = (position.x - bounds.origin.x).max(px(0.));
|
||||
let local_y = (position.y - bounds.origin.y).max(px(0.));
|
||||
let cell_width = px(self.terminal_cell_width());
|
||||
let line_height = px(self.terminal_line_height());
|
||||
let snapshot = self.active_snapshot()?;
|
||||
let max_col = snapshot.cols.saturating_sub(1);
|
||||
let max_row = snapshot.rows.saturating_sub(1);
|
||||
let col = ((local_x / cell_width).floor() as usize).min(max_col);
|
||||
let row = ((local_y / line_height).floor() as usize).min(max_row);
|
||||
let cell_offset_x = px(local_x.as_f32() % cell_width.as_f32());
|
||||
let side = if cell_offset_x >= (cell_width / 2.) {
|
||||
Side::Right
|
||||
} else {
|
||||
Side::Left
|
||||
};
|
||||
Some((row, col, side))
|
||||
}
|
||||
|
||||
pub(crate) fn on_terminal_scroll(
|
||||
&mut self,
|
||||
event: &ScrollWheelEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get coordinates before mutably borrowing tabs
|
||||
let grid_point = self.terminal_grid_point_and_side(event.position);
|
||||
|
||||
let line_height = self.terminal_line_height();
|
||||
|
||||
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == active_id) {
|
||||
let delta_lines = match event.delta {
|
||||
ScrollDelta::Lines(point) => point.y.round() as i32,
|
||||
ScrollDelta::Pixels(point) => {
|
||||
tab.scroll_pixel_y += point.y.as_f32();
|
||||
let lines = (tab.scroll_pixel_y / line_height).trunc() as i32;
|
||||
tab.scroll_pixel_y -= (lines as f32) * line_height;
|
||||
lines
|
||||
}
|
||||
};
|
||||
|
||||
if delta_lines == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mode = tab.term.mode();
|
||||
|
||||
let is_mouse_tracking = mode.intersects(
|
||||
alacritty_terminal::term::TermMode::MOUSE_REPORT_CLICK
|
||||
| alacritty_terminal::term::TermMode::MOUSE_MOTION
|
||||
| alacritty_terminal::term::TermMode::MOUSE_DRAG,
|
||||
);
|
||||
|
||||
let is_alternate_scroll = mode.contains(
|
||||
alacritty_terminal::term::TermMode::ALT_SCREEN
|
||||
| alacritty_terminal::term::TermMode::ALTERNATE_SCROLL,
|
||||
);
|
||||
|
||||
if is_mouse_tracking {
|
||||
if let Some((row, col, _)) = grid_point {
|
||||
let sgr = mode.contains(alacritty_terminal::term::TermMode::SGR_MOUSE);
|
||||
let button = if delta_lines > 0 { 64 } else { 65 };
|
||||
let times = delta_lines.abs();
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..times {
|
||||
if sgr {
|
||||
bytes.extend_from_slice(
|
||||
format!("\x1b[<{};{};{}M", button, col + 1, row + 1).as_bytes(),
|
||||
);
|
||||
} else {
|
||||
if col < 223 && row < 223 {
|
||||
bytes.extend_from_slice(b"\x1b[M");
|
||||
bytes.push(button as u8 + 32);
|
||||
bytes.push(col as u8 + 33);
|
||||
bytes.push(row as u8 + 33);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
tab.backend
|
||||
.send(crate::terminal::BackendCommand::Input(bytes));
|
||||
}
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
} else if is_alternate_scroll {
|
||||
let times = delta_lines.abs();
|
||||
let code = if delta_lines > 0 { b'A' } else { b'B' };
|
||||
let mut bytes = Vec::with_capacity((times * 3) as usize);
|
||||
for _ in 0..times {
|
||||
bytes.extend_from_slice(&[b'\x1b', b'O', code]);
|
||||
}
|
||||
if !bytes.is_empty() {
|
||||
tab.backend
|
||||
.send(crate::terminal::BackendCommand::Input(bytes));
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
return;
|
||||
}
|
||||
|
||||
tab.scroll_history(delta_lines);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,21 @@
|
||||
pub mod custom_blocks;
|
||||
pub mod element;
|
||||
pub mod input;
|
||||
pub mod highlight;
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use alacritty_terminal::{
|
||||
event::{Event, EventListener},
|
||||
grid::{Dimensions, Scroll},
|
||||
index::{Column, Point, Side},
|
||||
index::{Column, Line, Point, Side},
|
||||
selection::{Selection, SelectionRange, SelectionType},
|
||||
term::{
|
||||
cell::Cell,
|
||||
point_to_viewport, viewport_to_point, Config, Term, TermMode,
|
||||
},
|
||||
term::{Config, Term, TermMode, cell::Cell, point_to_viewport, viewport_to_point},
|
||||
vte::ansi::{CursorShape, Processor},
|
||||
};
|
||||
use gpui::Keystroke;
|
||||
|
||||
use crate::config::Session;
|
||||
use crate::session::config::Session;
|
||||
use crate::sftp::{PreviewData, RemoteEntry};
|
||||
use crate::system::SystemSnapshot;
|
||||
|
||||
@@ -23,25 +25,92 @@ pub enum TabKind {
|
||||
Ssh,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PromptType {
|
||||
KeyboardInteractive,
|
||||
Passphrase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptInfo {
|
||||
pub prompt: String,
|
||||
pub echo: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendCommand {
|
||||
Input(Vec<u8>),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
SampleMetrics,
|
||||
Close,
|
||||
PromptResponse(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
Output { tab_id: String, bytes: Vec<u8> },
|
||||
Status { tab_id: String, text: String },
|
||||
Connected { tab_id: String },
|
||||
SftpEntries { tab_id: String, path: String, entries: Vec<RemoteEntry> },
|
||||
SftpPreview { tab_id: String, preview: PreviewData },
|
||||
SftpStatus { tab_id: String, text: String },
|
||||
RemoteSystem { tab_id: String, snapshot: SystemSnapshot },
|
||||
RemoteSystemUnavailable { tab_id: String, reason: String },
|
||||
Closed { tab_id: String, reason: String },
|
||||
TerminalTitleChanged { tab_id: String, title: String },
|
||||
PromptRequest {
|
||||
tab_id: String,
|
||||
prompt_type: PromptType,
|
||||
instruction: String,
|
||||
prompts: Vec<PromptInfo>,
|
||||
},
|
||||
Output {
|
||||
tab_id: String,
|
||||
bytes: Vec<u8>,
|
||||
},
|
||||
Status {
|
||||
tab_id: String,
|
||||
text: String,
|
||||
},
|
||||
Connected {
|
||||
tab_id: String,
|
||||
},
|
||||
SftpEntries {
|
||||
tab_id: String,
|
||||
path: String,
|
||||
entries: Vec<RemoteEntry>,
|
||||
},
|
||||
SftpPreview {
|
||||
tab_id: String,
|
||||
preview: PreviewData,
|
||||
},
|
||||
SftpStatus {
|
||||
tab_id: String,
|
||||
text: String,
|
||||
},
|
||||
RemoteSystem {
|
||||
tab_id: String,
|
||||
snapshot: SystemSnapshot,
|
||||
},
|
||||
RemoteSystemUnavailable {
|
||||
tab_id: String,
|
||||
reason: String,
|
||||
},
|
||||
SftpHome {
|
||||
tab_id: String,
|
||||
home: String,
|
||||
},
|
||||
TransferProgress {
|
||||
#[allow(dead_code)]
|
||||
tab_id: String,
|
||||
id: String,
|
||||
transferred: u64,
|
||||
total: Option<u64>,
|
||||
state: TransferState,
|
||||
},
|
||||
TransferStarted {
|
||||
tab_id: String,
|
||||
info: TransferInfo,
|
||||
},
|
||||
Closed {
|
||||
tab_id: String,
|
||||
reason: String,
|
||||
},
|
||||
TerminalTitleChanged {
|
||||
tab_id: String,
|
||||
title: String,
|
||||
},
|
||||
SyncFinished(crate::sync::SyncResult),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -70,12 +139,13 @@ pub struct TerminalTab {
|
||||
pub status: String,
|
||||
pub connected: bool,
|
||||
pub session: Option<Session>,
|
||||
pub sftp: Option<SftpUiState>,
|
||||
processor: Processor,
|
||||
term: Term<TerminalListener>,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pub backend: BackendTx,
|
||||
pub scroll_pixel_y: f32,
|
||||
pub(crate) highlight_cache: std::cell::RefCell<Option<(Vec<RenderCell>, std::collections::HashMap<(i32, i32), gpui::Hsla>)>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -85,7 +155,7 @@ pub struct CursorState {
|
||||
pub shape: CursorShape,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct RenderCell {
|
||||
pub row: i32,
|
||||
pub col: i32,
|
||||
@@ -101,6 +171,7 @@ pub struct RenderSnapshot {
|
||||
pub history_size: usize,
|
||||
pub rows: usize,
|
||||
pub cols: usize,
|
||||
pub highlights: std::collections::HashMap<(i32, i32), gpui::Hsla>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -120,36 +191,56 @@ pub struct SftpUiState {
|
||||
pub selected_path: Option<String>,
|
||||
pub preview: Option<PreviewData>,
|
||||
pub selected_entries: std::collections::HashSet<String>,
|
||||
pub home_dir: String,
|
||||
}
|
||||
|
||||
impl TerminalTab {
|
||||
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_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, events: std::sync::mpsc::Sender<BackendEvent>) -> 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),
|
||||
format!(
|
||||
"connecting {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
backend,
|
||||
events,
|
||||
);
|
||||
tab.session = Some(session.clone());
|
||||
tab.connected = false;
|
||||
tab.sftp = Some(SftpUiState {
|
||||
current_path: "/".into(),
|
||||
status: "sftp connecting...".into(),
|
||||
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, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
|
||||
fn new(
|
||||
id: String,
|
||||
title: String,
|
||||
kind: TabKind,
|
||||
status: String,
|
||||
backend: BackendTx,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.clone(),
|
||||
title,
|
||||
@@ -157,12 +248,13 @@ impl TerminalTab {
|
||||
status,
|
||||
connected: matches!(kind, TabKind::Local),
|
||||
session: None,
|
||||
sftp: None,
|
||||
processor: Processor::new(),
|
||||
term: new_term(100, 30, backend.clone(), id, events),
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
backend,
|
||||
scroll_pixel_y: 0.0,
|
||||
highlight_cache: std::cell::RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,10 +263,19 @@ impl TerminalTab {
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||
self.cols = cols.max(1);
|
||||
self.rows = rows.max(1);
|
||||
self.term.resize(TerminalSize::new(self.cols, self.rows));
|
||||
self.backend.send(BackendCommand::Resize { cols, rows });
|
||||
let new_cols = cols.max(1);
|
||||
let new_rows = rows.max(1);
|
||||
if self.cols != new_cols || self.rows != new_rows {
|
||||
self.cols = new_cols;
|
||||
self.rows = new_rows;
|
||||
tracing::info!(
|
||||
"[ui] terminal resized to {}x{} (cols x rows)",
|
||||
self.cols,
|
||||
self.rows
|
||||
);
|
||||
self.term.resize(TerminalSize::new(self.cols, self.rows));
|
||||
self.backend.send(BackendCommand::Resize { cols, rows });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> Option<CursorState> {
|
||||
@@ -231,17 +332,72 @@ impl TerminalTab {
|
||||
});
|
||||
}
|
||||
|
||||
// Get highlights from cache or recompute, only if keyword_highlight is enabled.
|
||||
let is_enabled = crate::session::config::ConfigStore::load()
|
||||
.map(|c| c.keyword_highlight())
|
||||
.unwrap_or(false);
|
||||
|
||||
let highlights = if is_enabled {
|
||||
let mut cache = self.highlight_cache.borrow_mut();
|
||||
let cache_valid = cache.as_ref().map_or(false, |(cached_cells, _)| {
|
||||
cached_cells == &cells
|
||||
});
|
||||
if cache_valid {
|
||||
cache.as_ref().unwrap().1.clone()
|
||||
} else {
|
||||
let computed = self::highlight::highlight_cells(&cells, rows as usize);
|
||||
*cache = Some((cells.clone(), computed.clone()));
|
||||
computed
|
||||
}
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
RenderSnapshot {
|
||||
cells,
|
||||
cursor: self.cursor_state(),
|
||||
selection: viewport_selection_from_range(content.display_offset, &content.selection),
|
||||
selection: viewport_selection_from_range(
|
||||
content.display_offset,
|
||||
self.rows as usize,
|
||||
self.cols as usize,
|
||||
&content.selection,
|
||||
),
|
||||
display_offset: content.display_offset,
|
||||
history_size: self.term.grid().history_size(),
|
||||
rows: self.rows as usize,
|
||||
cols: self.cols as usize,
|
||||
highlights,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return `(grid_line_base, rows_data)` for the **entire** terminal buffer
|
||||
/// including scrollback history. `grid_line_base` is the grid line index of
|
||||
/// the first row (typically `-history_size`). Each entry in `rows_data` is
|
||||
/// a sorted `Vec<(col, char)>` for that row.
|
||||
pub fn full_grid_rows(&self) -> (i32, Vec<Vec<(i32, char)>>) {
|
||||
let grid = self.term.grid();
|
||||
let history = grid.history_size() as i32;
|
||||
let screen = grid.screen_lines() as i32;
|
||||
let total = history + screen;
|
||||
let cols = self.cols as i32;
|
||||
let start_line = -history;
|
||||
|
||||
let mut rows_data: Vec<Vec<(i32, char)>> = Vec::with_capacity(total as usize);
|
||||
for line_idx in start_line..(start_line + total) {
|
||||
let line = Line(line_idx);
|
||||
let mut cells: Vec<(i32, char)> = Vec::new();
|
||||
for col_idx in 0..cols {
|
||||
let point = Point::new(line, Column(col_idx as usize));
|
||||
let c = grid[point].c;
|
||||
if c != ' ' && c != '\0' {
|
||||
cells.push((col_idx, c));
|
||||
}
|
||||
}
|
||||
rows_data.push(cells);
|
||||
}
|
||||
(start_line, rows_data)
|
||||
}
|
||||
|
||||
pub fn scroll_history(&mut self, delta: i32) {
|
||||
if delta != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(delta));
|
||||
@@ -264,8 +420,11 @@ impl TerminalTab {
|
||||
self.term.scroll_display(Scroll::Bottom);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.term.selection_to_string().is_some_and(|text| !text.is_empty())
|
||||
self.term
|
||||
.selection_to_string()
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub fn clear_selection(&mut self) {
|
||||
@@ -273,7 +432,9 @@ impl TerminalTab {
|
||||
}
|
||||
|
||||
pub fn selection_text(&self) -> Option<String> {
|
||||
self.term.selection_to_string().filter(|text| !text.is_empty())
|
||||
self.term
|
||||
.selection_to_string()
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub fn begin_selection(
|
||||
@@ -301,27 +462,55 @@ impl TerminalTab {
|
||||
}
|
||||
|
||||
pub fn paste_text(&mut self, text: &str) {
|
||||
let paste_text = text.replace('\x1b', "").replace("\r\n", "\r").replace('\n', "\r");
|
||||
let paste_text = text
|
||||
.replace('\x1b', "")
|
||||
.replace("\r\n", "\r")
|
||||
.replace('\n', "\r");
|
||||
|
||||
self.backend
|
||||
.send(BackendCommand::Input(paste_text.into_bytes()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn viewport_selection_from_range(
|
||||
display_offset: usize,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
selection: &Option<SelectionRange>,
|
||||
) -> Option<ViewportSelection> {
|
||||
let SelectionRange { start, end, is_block } = selection.as_ref().copied()?;
|
||||
let start = point_to_viewport(display_offset, start)?;
|
||||
let end = point_to_viewport(display_offset, end)?;
|
||||
let SelectionRange {
|
||||
start,
|
||||
end,
|
||||
is_block,
|
||||
} = selection.as_ref().copied()?;
|
||||
|
||||
let top_point = viewport_to_point(display_offset, Point::new(0, Column(0)));
|
||||
let bottom_point = viewport_to_point(display_offset, Point::new(rows.saturating_sub(1), Column(0)));
|
||||
|
||||
let top_line = top_point.line;
|
||||
let bottom_line = bottom_point.line;
|
||||
|
||||
let start_vp = if start.line < top_line {
|
||||
Point::new(0, Column(0))
|
||||
} else if start.line > bottom_line {
|
||||
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
|
||||
} else {
|
||||
point_to_viewport(display_offset, start).unwrap_or(Point::new(0, Column(0)))
|
||||
};
|
||||
|
||||
let end_vp = if end.line < top_line {
|
||||
Point::new(0, Column(0))
|
||||
} else if end.line > bottom_line {
|
||||
Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1)))
|
||||
} else {
|
||||
point_to_viewport(display_offset, end).unwrap_or(Point::new(rows.saturating_sub(1), Column(cols.saturating_sub(1))))
|
||||
};
|
||||
|
||||
Some(ViewportSelection {
|
||||
start_row: start.line,
|
||||
start_col: start.column.0,
|
||||
end_row: end.line,
|
||||
end_col: end.column.0,
|
||||
start_row: start_vp.line,
|
||||
start_col: start_vp.column.0,
|
||||
end_row: end_vp.line,
|
||||
end_col: end_vp.column.0,
|
||||
is_block,
|
||||
})
|
||||
}
|
||||
@@ -336,7 +525,9 @@ struct TerminalListener {
|
||||
impl EventListener for TerminalListener {
|
||||
fn send_event(&self, event: Event) {
|
||||
match event {
|
||||
Event::PtyWrite(output) => self.backend.send(BackendCommand::Input(output.into_bytes())),
|
||||
Event::PtyWrite(output) => self
|
||||
.backend
|
||||
.send(BackendCommand::Input(output.into_bytes())),
|
||||
Event::TextAreaSizeRequest(format) => {
|
||||
let size = alacritty_terminal::event::WindowSize {
|
||||
num_lines: 30,
|
||||
@@ -358,14 +549,24 @@ impl EventListener for TerminalListener {
|
||||
}
|
||||
}
|
||||
|
||||
fn new_term(cols: u16, rows: u16, backend: BackendTx, tab_id: String, events: std::sync::mpsc::Sender<BackendEvent>) -> 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 { tab_id, backend, events },
|
||||
TerminalListener {
|
||||
tab_id,
|
||||
backend,
|
||||
events,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -397,7 +598,11 @@ impl Dimensions for TerminalSize {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_key(keystroke: &Keystroke, app_cursor_mode: bool, option_as_meta: bool) -> Option<Vec<u8>> {
|
||||
pub fn encode_key(
|
||||
keystroke: &Keystroke,
|
||||
app_cursor_mode: bool,
|
||||
option_as_meta: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
zed_like_to_esc_str(keystroke, app_cursor_mode, option_as_meta)
|
||||
.map(|text| text.into_owned().into_bytes())
|
||||
}
|
||||
@@ -567,3 +772,75 @@ fn modifier_code(keystroke: &Keystroke) -> u32 {
|
||||
}
|
||||
modifier_code + 1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum TransferType {
|
||||
Upload,
|
||||
Download,
|
||||
}
|
||||
|
||||
#[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,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub kind: TransferType,
|
||||
pub total_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Transfer {
|
||||
pub tab_id: String,
|
||||
pub tab_title: String,
|
||||
pub info: TransferInfo,
|
||||
pub transferred: u64,
|
||||
pub total: Option<u64>,
|
||||
pub state: TransferState,
|
||||
}
|
||||
Reference in New Issue
Block a user