Compare commits

...
43 Commits
Author SHA1 Message Date
TomZz 47ec91d099 bump version to 0.5.2-fix1 2026-10-03 17:46:13 +08:00
TomZz e2501ca764 fix(config): 修复终端与界面字号在特定数值下重启被重置的问题 (#117) 2026-10-03 17:30:01 +08:00
TomZz a34a673b52 bump version to 0.5.2 2026-10-03 17:19:57 +08:00
TomZz 0708ac7baa fix(app): 优化系统监控与会话选择,修复分屏 SFTP 及窗口激活问题 (#119) 2026-10-03 17:19:24 +08:00
TomZz ff4079a60f bump version to 0.5.1 2026-08-29 22:49:18 +08:00
TomZz 9d0868dd7c Merge pull request #116 from realmx/fix/terminal-tab-activity-url-boundaries
修复终端标签活动状态与 URL 高亮边界
2026-08-29 22:47:07 +08:00
TomZz f63b8b418c feat(terminal): support switching tabs when focusing adjacent pane at layout boundaries 2026-08-29 22:40:09 +08:00
Realm e1d39d8dc4 fix(terminal): 对齐标签活动状态并修正链接边界
- 标签 Loading 仅响应后台标签最近两秒的真实输出
- 排除 OSC 控制序列并补充终端活动状态回归测试
- 修正 URL 在中文标点和未配对括号后的高亮范围
2026-08-29 19:55:58 +08:00
TomZz c9f722071a build(ci): add dedicated macos-arm64 preview build workflow 2026-08-28 03:41:30 +08:00
TomZz 2e279e206f feat(ui): add keybinding usage tips and shortcut conflict guidance to about page 2026-08-28 03:37:26 +08:00
Realm 7b410afea7 fix(app): resolve connection export modal and terminal history glitches (#115)
- Fix CSV connection export confirmation dialog not opening within settings page
- Remove redundant connect button on connection rows in sidebar edit mode
- Enhance terminal syntax highlighting with background color, inverse, dim, and CJK wide char support
- Improve command history tracking accuracy using buffer-relative coordinates and residue filtering
2026-08-28 03:12:54 +08:00
TomZz b76f9c7375 bump version to 0.5.0 2026-08-27 01:37:30 +08:00
TomZz 28fc1a6b50 fix(theme): fallback to embedded Maple font when system UI fonts are missing (#92)
- Add resolve_safe_ui_font to gracefully resolve UI fonts across all platforms.
- If default system UI fonts (.SystemUIFont) or requested custom fonts cannot be resolved (e.g. on minimal Linux distros like Gentoo/Arch without desktop fonts installed), automatically fallback to embedded Maple Mono NF CN font.
- Prevents GPUI runtime text system panic: 'failed to resolve font .SystemUIFont or any of the fallbacks'.
2026-08-27 01:20:47 +08:00
TomZz 52741e3c32 refactor(ui): redesign sidebar header, connection row click interaction, and tab bar dropdown
- Redesign sidebar layout and management mode:
  - Add explicit "Edit" / "Done" toggle button in the sidebar header to toggle connection management mode cleanly.
  - Remove top sidebar Import/Export buttons and move them into the Settings dialog ("配置文件与备份" / Config File & Backup page).
  - Remove the bottom right Switch toggle widget from sidebar layout.
  - Add a full-width "+ 新增分组" button below the connection filter search box in Edit Mode.
- Improve connection item click UX:
  - Make the entire connection row clickable in non-edit mode to immediately launch saved sessions.
  - Hide redundant external link icon button in non-edit mode for a cleaner sidebar appearance.
  - Allow user resizing of sidebar width down to 200px while maintaining default 310px.
- Fix tab bar hover panic & dropdown navigation:
  - Remove duplicate `.hover(...)` call on tab close button to resolve GPUI runtime debug assert panic ("hover style already set").
  - Update tab bar '+' dropdown menu second item to "打开连接" (Open Connection), triggering the Session Selector Dialog (`show_selector_dialog`).
  - Add corresponding localization strings in English and Simplified Chinese.
2026-08-27 01:00:20 +08:00
Realm 3ef6e8cfb8 fix(terminal): 修复 SSH 历史命令残留与光标定位 (#114) 2026-08-27 00:09:40 +08:00
Realm 6ccb9ff601 feat(ui): 优化侧边栏连接管理与窗口激活通知 (#113) 2026-08-27 00:09:38 +08:00
Realm 168a20e157 fix: 完善终端拖拽、Windows 细节与细节修正 (#112) 2026-08-27 00:01:03 +08:00
Realm ee98a933e6 feat(ui): 重构工作区交互与系统菜单 (#111) 2026-08-27 00:01:03 +08:00
Realm b4551ee1cc feat(terminal): 扩展跨平台通知协议与折叠管理 (#110) 2026-08-27 00:01:02 +08:00
Realm 233cde12e8 build/ci/docs: 拆分构建、打包与更新日志 (#109) 2026-08-27 00:01:02 +08:00
Realm b1efaa1d8f feat: 增加连接分组管理 (#108) 2026-08-27 00:00:58 +08:00
Realm 4607b2c995 拆分工作区 UI 与对话框基础 (#107)
* feat: 拆分终端与会话基础能力

* feat: 拆分应用状态与布局基础

* feat: 拆分工作区 UI 与对话框基础
2026-08-22 22:00:59 +08:00
Realm f6816da18b 拆分应用状态与布局基础 (#106)
* feat: 拆分终端与会话基础能力

* feat: 拆分应用状态与布局基础
2026-08-22 21:54:56 +08:00
Realm 350dcbc5b4 feat: 拆分终端与会话基础能力 (#105) 2026-08-21 14:24:17 +08:00
TomZz 44348d4d75 feat: support mapping F1-F12 keys to ANSI escape sequences (resolves #91) 2026-07-23 00:26:28 +08:00
TomZz 9163d3a970 bump version to 0.4.12 2026-07-23 00:12:10 +08:00
TomZz d85c1f4f55 perf: fix sluggishness when switching focus between panes 2026-07-23 00:06:54 +08:00
TomZz 61e7fb7b0d feat: optimize ssh keepalive timeout settings (fixes #86) 2026-07-22 23:58:17 +08:00
TomZz 1bb922f955 feat: add local configuration export and import settings 2026-07-22 23:58:17 +08:00
TomZz a06d1c668d feat: support unbinding shortcuts by pressing Backspace 2026-07-22 23:58:17 +08:00
TomZz 3b9ee02cce feat: fix Windows flashing, UI thread blocking, and idle redraws (fixes #90) 2026-07-22 23:58:17 +08:00
TomZz e7ca7bdc23 feat: add support for serial port connections with virtual PTY fallback and automatic newline translation 2026-07-20 17:06:21 +08:00
TomZz 84eccb5521 bump version to 0.4.11 2026-07-15 03:24:39 +08:00
TomZz 4da9949c84 perf: avoid reading and decrypting config file from disk repeatedly on every render frame 2026-07-15 03:23:36 +08:00
TomZz 85fb354f80 fix: add a 16-second connection timeout to tcp and proxy connections 2026-07-15 03:23:36 +08:00
TomZz 584f962298 feat: implement manual path sync from terminal to file manager (fixes #83) 2026-07-15 03:23:36 +08:00
TomZz a32fd9437d style: run cargo fmt 2026-07-15 02:26:16 +08:00
TomZz 4c292c060c fix: clear connection_progress on tab close to resolve stuck modal (fixes #82) 2026-07-15 02:20:08 +08:00
TomZz c0a954ea0c feat: implement hardware-encrypted configuration file 2026-07-15 02:20:06 +08:00
TomZz ab41ccbd6b bump version to 0.4.10 2026-07-08 19:55:29 +08:00
TomZz 43717e4378 fix(ssh): improve ssh config parser robustness and revert gitignore changes 2026-07-08 19:51:40 +08:00
tg.shi 31218f7a37 feat(ssh): connect via ~/.ssh/config entries
Add AuthMethod::Config: parse ~/.ssh/config and list concrete host
entries in the connection dialog. Selecting an entry pre-fills
host/port/user/identity file and connects.

- Extract shared key utilities into session::ssh_keys
  (private_keys_with_algs, normalize_inline_private_key,
  authenticate_with_default_keys, session_has_explicit_key), reused by
  backend::ssh and sftp to remove duplication.
- Add session::ssh_config parser for ~/.ssh/config (Host/HostName/User/
  Port/IdentityFile), skipping wildcard patterns and Match/Include.
- Key auth now falls back to default keys (~/.ssh/id_*) when no explicit
  key path or inline content is provided.
- Preserve escaped \r\n normalization in normalize_inline_private_key
  when refactoring into the shared module.
2026-07-08 11:50:51 +08:00
TomZz af5c2831f6 fix: remove redundant window drag area to fix window control overlay (resolves #73) 2026-07-04 12:11:42 +08:00
48 changed files with 22938 additions and 4095 deletions
+12 -6
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: ["main"]
branches: ["main", "master"]
pull_request:
branches: ["main"]
branches: ["main", "master"]
jobs:
build:
@@ -14,18 +14,24 @@ jobs:
fail-fast: false
matrix:
include:
- os: windows-latest
- os: windows-2022
target: x86_64-pc-windows-msvc
name: windows-x86_64
name: windows-x64
- os: windows-2022
target: i686-pc-windows-msvc
name: windows-x86
- os: windows-2022
target: aarch64-pc-windows-msvc
name: windows-arm64
- 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
name: macos-arm64
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
name: macos-x64
steps:
- uses: actions/checkout@v4
+358 -51
View File
@@ -1,42 +1,80 @@
name: Release
# Build native binaries for Windows / Linux / macOS.
# Build native installers and portable archives for Windows / Linux / macOS.
# - Push a tag like `v0.2.3` -> builds all platforms and attaches the archives
# to a GitHub Release.
# - Run manually (workflow_dispatch) -> builds all platforms and uploads them as
# downloadable workflow artifacts (no release created).
# - Push a `build/**` branch -> builds preview installers as workflow artifacts.
# - Run manually (workflow_dispatch) -> builds all platforms by default, or only
# the selected target, and uploads them as downloadable workflow artifacts.
on:
push:
branches: ["build/**"]
tags: ["v*"]
workflow_dispatch:
inputs:
platform:
description: "Build target"
required: true
type: choice
default: all
options:
- all
- windows-x64
- macos-arm64
permissions:
contents: write # needed to create / update the GitHub Release
jobs:
build:
if: >-
github.ref != 'refs/heads/build/windows-x64' &&
github.ref != 'refs/heads/build/macos-arm64' &&
(github.event_name != 'workflow_dispatch' || inputs.platform == 'all')
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
- os: windows-2022
target: x86_64-pc-windows-msvc
name: windows-x86_64
name: windows-x64
kind: windows
arch: x64
bin: ashell.exe
- os: windows-2022
target: i686-pc-windows-msvc
name: windows-x86
kind: windows
arch: x86
bin: ashell.exe
- os: windows-2022
target: aarch64-pc-windows-msvc
name: windows-arm64
kind: windows
arch: arm64
bin: ashell.exe
- os: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
target: x86_64-unknown-linux-gnu
name: linux-x86_64
name: linux-x64
kind: linux
arch: x64
bin: ashell
- os: macos-14 # Apple Silicon
target: aarch64-apple-darwin
name: macos-aarch64
name: macos-arm64
kind: macos
arch: arm64
binary_arch: arm64
bin: ashell
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
target: x86_64-apple-darwin
name: macos-x86_64
name: macos-x64
kind: macos
arch: x64
binary_arch: x86_64
bin: ashell
steps:
@@ -68,28 +106,46 @@ jobs:
- name: Build (release)
run: cargo build --release --target ${{ matrix.target }}
- name: Package
- name: Set package metadata
shell: bash
run: |
set -eu
VERSION="${GITHUB_REF_NAME:-dev}"
STAGE="ashell-${VERSION}-${{ matrix.name }}"
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
if [ "${{ runner.os }}" = "macOS" ]; then
# ── Build a proper .app bundle ────────────────────────────────────
# Note: We build the bundle manually here rather than running
# scripts/package-macos-app.sh, because that script runs `cargo build`
# without respecting the `--target` flag from the matrix.
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
PACKAGE_VERSION="${GITHUB_REF_NAME#v}"
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "Tag version $PACKAGE_VERSION does not match Cargo.toml version $VERSION" >&2
exit 1
fi
else
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
fi
APP="ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-${{ matrix.name }}" >> "$GITHUB_ENV"
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
- name: Package macOS app, DMG, and portable archive
if: matrix.kind == 'macos'
shell: bash
run: |
set -euo pipefail
APP_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-app"
DMG_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-dmg"
APP="$APP_ROOT/ashell.app"
CONTENTS="$APP/Contents"
VERSION_NUM="${VERSION#v}"
cat > "$CONTENTS/Info.plist" <<EOF
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DMG_ROOT" dist
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/ashell"
chmod 755 "$CONTENTS/MacOS/ashell"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -110,9 +166,9 @@ jobs:
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION_NUM}</string>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
@@ -121,35 +177,284 @@ jobs:
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
printf 'APPL????' > "$CONTENTS/PkgInfo"
plutil -lint "$CONTENTS/Info.plist"
# Ad-hoc code-sign the assembled bundle.
codesign --force --deep --sign - "$APP"
codesign --verify --verbose "$APP"
# Release builds remain ad-hoc signed until signing credentials are configured.
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
# Package with ditto (Apple's tool) to preserve signature/metadata
ditto -c -k --keepParent "$APP" "${STAGE}.zip"
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
ditto -c -k --sequesterRsrc --keepParent \
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
ditto "$APP" "$DMG_ROOT/ashell.app"
ln -s /Applications "$DMG_ROOT/Applications"
hdiutil create \
-volname "ashell ${VERSION}" \
-srcfolder "$DMG_ROOT" \
-ov \
-format UDZO \
"dist/${PACKAGE_BASENAME}.dmg"
elif [ "${{ runner.os }}" = "Windows" ]; then
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
7z a "${STAGE}.zip" "$STAGE" >/dev/null
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
else
# Linux
mkdir "$STAGE"
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
tar -czf "${STAGE}.tar.gz" "$STAGE"
echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV"
EXPECTED_ARCH="${{ matrix.binary_arch }}"
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
exit 1
fi
shasum -a 256 dist/*
- name: Package Windows installer and portable archive
if: matrix.kind == 'windows'
shell: pwsh
env:
ASHELL_ARCH: ${{ matrix.arch }}
ASHELL_BINARY_PATH: ${{ github.workspace }}\target\${{ matrix.target }}\release\ashell.exe
ASHELL_OUTPUT_DIR: ${{ github.workspace }}\dist
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Path "dist" -Force | Out-Null
$isccCommand = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if ($null -ne $isccCommand) {
$iscc = $isccCommand.Source
} else {
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
}
if (-not (Test-Path $iscc)) {
throw "Inno Setup compiler was not found"
}
& $iscc "packaging\windows\ashell.iss"
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup failed with exit code $LASTEXITCODE"
}
$installer = "dist\${env:PACKAGE_BASENAME}-setup.exe"
if (-not (Test-Path $installer)) {
throw "Expected installer was not created: $installer"
}
$stage = Join-Path $env:RUNNER_TEMP $env:PACKAGE_BASENAME
New-Item -ItemType Directory -Path $stage | Out-Null
Copy-Item $env:ASHELL_BINARY_PATH (Join-Path $stage "ashell.exe")
Compress-Archive -Path $stage -DestinationPath "dist\${env:PACKAGE_BASENAME}-portable.zip"
Get-FileHash -Algorithm SHA256 "dist\*"
- name: Package Linux portable archive
if: matrix.kind == 'linux'
shell: bash
run: |
set -euo pipefail
STAGE="$RUNNER_TEMP/$PACKAGE_BASENAME"
mkdir -p "$STAGE" dist
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
tar -C "$RUNNER_TEMP" -czf "dist/${PACKAGE_BASENAME}.tar.gz" "$PACKAGE_BASENAME"
sha256sum dist/*
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}
path: ${{ env.ASSET }}
path: dist/*
if-no-files-found: error
retention-days: 14
build-windows-x64:
if: >-
github.ref == 'refs/heads/build/windows-x64' ||
(github.event_name == 'workflow_dispatch' && inputs.platform == 'windows-x64')
name: windows-x64
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: x86_64-pc-windows-msvc
- name: Build (release)
run: cargo build --release --target x86_64-pc-windows-msvc
- name: Set package metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^\"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-windows-x64" >> "$GITHUB_ENV"
- name: Package Windows installer and portable archive
shell: pwsh
env:
ASHELL_ARCH: x64
ASHELL_BINARY_PATH: ${{ github.workspace }}\target\x86_64-pc-windows-msvc\release\ashell.exe
ASHELL_OUTPUT_DIR: ${{ github.workspace }}\dist
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Path "dist" -Force | Out-Null
$isccCommand = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if ($null -ne $isccCommand) {
$iscc = $isccCommand.Source
} else {
$iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
}
if (-not (Test-Path $iscc)) {
throw "Inno Setup compiler was not found"
}
& $iscc "packaging\windows\ashell.iss"
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup failed with exit code $LASTEXITCODE"
}
$installer = "dist\${env:PACKAGE_BASENAME}-setup.exe"
if (-not (Test-Path $installer)) {
throw "Expected installer was not created: $installer"
}
$stage = Join-Path $env:RUNNER_TEMP $env:PACKAGE_BASENAME
New-Item -ItemType Directory -Path $stage | Out-Null
Copy-Item $env:ASHELL_BINARY_PATH (Join-Path $stage "ashell.exe")
Compress-Archive -Path $stage -DestinationPath "dist\${env:PACKAGE_BASENAME}-portable.zip"
Get-FileHash -Algorithm SHA256 "dist\*"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: windows-x64
path: dist/*
if-no-files-found: error
retention-days: 14
build-macos-arm64:
if: >-
github.ref == 'refs/heads/build/macos-arm64' ||
(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos-arm64')
name: macos-arm64
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
with:
key: aarch64-apple-darwin
- name: Build (release)
run: cargo build --release --target aarch64-apple-darwin
- name: Set package metadata
shell: bash
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
if [ -z "$VERSION" ]; then
echo "Unable to read the package version from Cargo.toml" >&2
exit 1
fi
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-macos-arm64" >> "$GITHUB_ENV"
- name: Package macOS app, DMG, and portable archive
shell: bash
run: |
set -euo pipefail
APP_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-app"
DMG_ROOT="$RUNNER_TEMP/${PACKAGE_BASENAME}-dmg"
APP="$APP_ROOT/ashell.app"
CONTENTS="$APP/Contents"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" "$DMG_ROOT" dist
cp "target/aarch64-apple-darwin/release/ashell" "$CONTENTS/MacOS/ashell"
chmod 755 "$CONTENTS/MacOS/ashell"
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
cat > "$CONTENTS/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>ashell</string>
<key>CFBundleIconFile</key>
<string>ashell.icns</string>
<key>CFBundleIdentifier</key>
<string>dev.ashell.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ashell</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundleVersion</key>
<string>${GITHUB_RUN_NUMBER}</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
printf 'APPL????' > "$CONTENTS/PkgInfo"
plutil -lint "$CONTENTS/Info.plist"
codesign --force --deep --sign - "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
ditto -c -k --sequesterRsrc --keepParent \
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
ditto "$APP" "$DMG_ROOT/ashell.app"
ln -s /Applications "$DMG_ROOT/Applications"
hdiutil create \
-volname "ashell ${VERSION}" \
-srcfolder "$DMG_ROOT" \
-ov \
-format UDZO \
"dist/${PACKAGE_BASENAME}.dmg"
EXPECTED_ARCH="arm64"
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
exit 1
fi
shasum -a 256 dist/*
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: macos-arm64
path: dist/*
if-no-files-found: error
retention-days: 14
publish:
name: Publish Release
@@ -161,11 +466,12 @@ jobs:
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Attach to GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/**/*
files: dist/*
generate_release_notes: true
fail_on_unmatched_files: true
@@ -187,12 +493,13 @@ jobs:
with:
pattern: macos-*
path: dist
merge-multiple: true
- 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)
SHA_ARM=$(sha256sum "dist/ashell-v${VERSION}-macos-arm64.dmg" | cut -d' ' -f1)
SHA_INTEL=$(sha256sum "dist/ashell-v${VERSION}-macos-x64.dmg" | cut -d' ' -f1)
mkdir -p tap/Casks
CASK_FILE="tap/Casks/ashell.rb"
@@ -203,11 +510,11 @@ jobs:
on_arm do
sha256 "${SHA_ARM}"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-aarch64.zip"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-arm64.dmg"
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"
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-x64.dmg"
end
name "ashell"
+2 -1
View File
@@ -1,2 +1,3 @@
/target/
dist/
target/
.DS_Store
+39
View File
@@ -0,0 +1,39 @@
# 更新日志
本文档记录 ashell 的重要功能变更。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循
[语义化版本](https://semver.org/lang/zh-CN/)。
## [v0.5.0] - 2026-08-27
### 新增
- 支持 ANSI 转义序列中的 F1–F12 功能键映射,解决在终端工具(如 `htop`、`mc`、`vim` 等)中按 F1–F12 无法响应的问题 (#91)。
- 连接管理新增分组功能,支持创建分组、重命名、删除分组、折叠展开与按组归类管理连接 (#108)。
- 扩展终端通知协议支持,兼容 OSC 9、OSC 99、OSC 777 和 BEL (响铃),支持 macOS Dock 与 Windows 任务栏未读徽标,后台标签页显示闪烁提醒,点击通知可自动唤醒窗口并切换至对应标签页 (#110)。
- 新增系统级应用菜单,提供包含关于、文件、编辑、视图、窗口和帮助等常用操作 (#111)。
### 变更
- 重新设计侧边栏连接管理交互:侧边栏头部增加显式的“编辑 / 完成”模式切换按钮,编辑模式下在搜索框下方提供全宽“+ 新增分组”按钮,并移除侧边栏右下角切换 Switch 开关。
- 优化侧边栏连接项点击体验:非编辑模式下点击整行即可直接发起连接并隐藏冗余外链图标;允许侧边栏宽度自定义拖拽调小至 200px。
- 将 CSV 导入与导出入口位置由侧边栏头部整合移至设置对话框的“配置文件与备份”页面中。
- 优化标签栏加号下拉菜单:将第二个菜单项更新为“打开连接”,点击后直接弹出连接选择器对话框。
- 支持侧边栏完全折叠隐藏,折叠后隐藏连接列表并最大化终端界面绘制区域。
### 修复
- 修复 SSH 历史命令输入时的残留字符与光标定位偏差问题 (#114)。
- 优化 Windows 平台下终端拖拽与窗口细节表现 (#112)。
### 兼容性与稳定性
- 代码架构模块化重构:将核心代码拆分为 `session`、`terminal`、`app`、`backend`、`sftp` 与 `sync` 模块,提升可维护性 (#105-#107)。
- 优化 GitHub Actions CI 构建工作流 (#109)。
### 注意事项
- CSV 导出的密码为明文格式,请妥善保管并在迁移后及时删除导出的 CSV 文件。
Generated
+321 -33
View File
@@ -315,7 +315,7 @@ dependencies = [
[[package]]
name = "ashell"
version = "0.4.9"
version = "0.5.2-fix1"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -324,7 +324,9 @@ dependencies = [
"base64",
"chacha20poly1305",
"chrono",
"csv",
"directories",
"encoding_rs",
"flate2",
"futures",
"gpui",
@@ -334,11 +336,18 @@ dependencies = [
"hex",
"hmac",
"image",
"libc",
"mac-notification-sys",
"menu",
"notify 6.1.1",
"notify-rust",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"portable-pty",
"rand 0.8.6",
"raw-window-handle",
"reqwest",
"rfd",
"russh",
@@ -347,11 +356,14 @@ dependencies = [
"rust-i18n 3.1.5",
"serde",
"serde_json",
"serialport",
"sha2",
"ssh-key",
"sys-locale",
"sysinfo 0.33.1",
"tar",
"tauri-winrt-notification",
"tempfile",
"thiserror 1.0.69",
"time",
"tokio",
@@ -361,6 +373,8 @@ dependencies = [
"tracing-subscriber",
"uuid",
"walkdir",
"windows 0.57.0",
"winreg 0.52.0",
"winres",
"zip",
]
@@ -875,8 +889,7 @@ dependencies = [
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
source = "git+https://github.com/Dicklesworthstone/rust-block?rev=b39ae859d1ee8e8cb5eef6a516471f1578d26b96#b39ae859d1ee8e8cb5eef6a516471f1578d26b96"
[[package]]
name = "block-buffer"
@@ -1588,6 +1601,27 @@ dependencies = [
"typenum",
]
[[package]]
name = "csv"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
dependencies = [
"csv-core",
"itoa",
"ryu",
"serde_core",
]
[[package]]
name = "csv-core"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
dependencies = [
"memchr",
]
[[package]]
name = "ctor"
version = "1.0.7"
@@ -2760,7 +2794,7 @@ dependencies = [
"itertools 0.14.0",
"log",
"lyon",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"num_cpus",
@@ -2950,7 +2984,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"media",
"metal",
"objc",
@@ -3683,6 +3717,16 @@ dependencies = [
"rustversion",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2 0.4.3",
]
[[package]]
name = "io-surface"
version = "0.16.1"
@@ -3952,6 +3996,26 @@ dependencies = [
"libc",
]
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "linebender_resource_handle"
version = "0.1.1"
@@ -4099,6 +4163,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
"time",
"uuid",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -4397,6 +4484,17 @@ dependencies = [
"pin-utils",
]
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -4502,6 +4600,18 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "notify-rust"
version = "4.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fa3b9f2364a09bd359aa0206702882e208437450866a374d5372d64aece4029"
dependencies = [
"futures-lite 2.6.1",
"log",
"mac-notification-sys",
"tauri-winrt-notification",
]
[[package]]
name = "notify-types"
version = "1.0.1"
@@ -4729,8 +4839,8 @@ dependencies = [
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data",
"objc2-core-image",
"objc2-core-data 0.2.2",
"objc2-core-image 0.2.2",
"objc2-foundation 0.2.2",
"objc2-quartz-core 0.2.2",
]
@@ -4743,6 +4853,26 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.12.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-cloud-kit",
"objc2-core-data 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image 0.3.2",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
@@ -4759,6 +4889,17 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -4770,6 +4911,19 @@ dependencies = [
"objc2 0.6.4",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.12.1",
"dispatch2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.2.2"
@@ -4782,6 +4936,41 @@ dependencies = [
"objc2-metal 0.2.2",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -4805,6 +4994,19 @@ name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.12.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4",
"objc2-core-foundation",
]
[[package]]
name = "objc2-io-surface"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.12.1",
"objc2 0.6.4",
@@ -5508,28 +5710,6 @@ dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -5598,6 +5778,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -6897,6 +7086,25 @@ dependencies = [
"serial-core",
]
[[package]]
name = "serialport"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.12.1",
"cfg-if",
"core-foundation 0.10.0",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2 0.4.3",
"nix 0.26.4",
"scopeguard",
"unescaper",
"windows-sys 0.52.0",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -7220,10 +7428,7 @@ dependencies = [
[[package]]
name = "stacksafe-macro"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69"
dependencies = [
"proc-macro-error2",
"quote",
"syn",
]
@@ -7560,6 +7765,17 @@ dependencies = [
"xattr 1.6.1",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89f5fb70d6f62381f5d9b2ba9008196150b40b75f3068eb24faeddf1c686871"
dependencies = [
"quick-xml 0.31.0",
"windows 0.56.0",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -8133,6 +8349,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "unescaper"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -8310,7 +8535,7 @@ dependencies = [
"itertools 0.14.0",
"libc",
"log",
"mach2",
"mach2 0.5.0",
"nix 0.29.0",
"percent-encoding",
"regex",
@@ -8945,6 +9170,16 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
dependencies = [
"windows-core 0.56.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.57.0"
@@ -9021,6 +9256,18 @@ dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
dependencies = [
"windows-implement 0.56.0",
"windows-interface 0.56.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.57.0"
@@ -9094,6 +9341,17 @@ dependencies = [
"windows-threading 0.2.1",
]
[[package]]
name = "windows-implement"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-implement"
version = "0.57.0"
@@ -9127,6 +9385,17 @@ dependencies = [
"syn",
]
[[package]]
name = "windows-interface"
version = "0.56.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.57.0"
@@ -9398,6 +9667,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
@@ -9563,6 +9841,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
+35 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ashell"
version = "0.4.9"
version = "0.5.2-fix1"
edition = "2024"
authors = ["ashell contributors"]
description = "A GPUI Component based SSH and local terminal client"
@@ -13,7 +13,9 @@ tokio-socks = "0.5"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
csv = "1"
directories = "5"
encoding_rs = "0.8"
flate2 = "1"
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
@@ -32,6 +34,7 @@ serde_json = "1"
ssh-key = "0.6"
sysinfo = "0.33"
tar = "0.4"
tempfile = "3"
thiserror = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
tracing = "0.1"
@@ -50,10 +53,36 @@ base64 = "0.22"
chacha20poly1305 = "0.10"
hmac = "0.12"
hex = "0.4"
serialport = "4"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
sha2 = "0.10"
[dev-dependencies]
libc = "0.2"
[target.'cfg(target_os = "windows")'.dependencies]
raw-window-handle = "0.6"
winrt-notification = { package = "tauri-winrt-notification", version = "=0.2.1" }
windows = { version = "0.57", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
"UI_Notifications",
] }
winreg = "0.52"
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
desktop-notify = { package = "notify-rust", version = "=4.11.5", default-features = false }
[target.'cfg(target_os = "macos")'.dependencies]
mac-notification-sys = "=0.6.15"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSDockTile"] }
objc2-foundation = { version = "0.3.2", features = ["NSString", "NSUserNotification"] }
[package.metadata.deb]
maintainer = "ashell contributors"
license-file = ["LICENSE"]
@@ -74,3 +103,8 @@ strip = true
[build-dependencies]
winres = "0.1.12"
# Remove these overrides after GPUI moves off block 0.1 and stacksafe 0.1.
[patch.crates-io]
block = { git = "https://github.com/Dicklesworthstone/rust-block", rev = "b39ae859d1ee8e8cb5eef6a516471f1578d26b96" }
stacksafe-macro = { path = "patches/stacksafe-macro" }
+47 -3
View File
@@ -20,7 +20,11 @@ v0.4 builds on the v0.3 foundation and focuses on more capable workspace operati
## Download
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).
You can download the latest installers from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest):
- macOS: `.dmg` installers for x64 and arm64, plus portable `.zip` archives.
- Windows: `Setup.exe` installers for x64, x86, and arm64, plus portable `.zip` archives.
- Linux: a portable x64 `.tar.gz` archive.
## Mac Installation Guide
@@ -43,14 +47,27 @@ brew upgrade ashell --cask
### 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.
1. Download the `.dmg` matching your processor from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
2. Open the DMG and drag `ashell.app` into **Applications**.
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
```
## Windows Installation Guide
Download the appropriate installer from the [Releases page](https://github.com/rust-kotlin/ashell/releases/latest):
| System architecture | Installer suffix |
| --- | --- |
| Intel/AMD 64-bit | `windows-x64-setup.exe` |
| Intel/AMD 32-bit | `windows-x86-setup.exe` |
| Windows on ARM | `windows-arm64-setup.exe` |
The installer can optionally create a desktop shortcut and registers ashell in the Start menu and the Windows uninstall list.
The installer is not yet signed with a commercial code-signing certificate, so Windows SmartScreen may display a security warning.
## Features
The current version provides a fully-featured GPUI-native workspace:
@@ -73,6 +90,33 @@ To run the application locally:
cargo run --release
```
## Codex and Terminal Completion Notifications
ashell supports generic OSC 9 completion notifications. For Codex CLI, add the following to
`~/.codex/config.toml`:
```toml
[tui]
notifications = ["agent-turn-complete", "approval-requested"]
notification_method = "osc9"
notification_condition = "unfocused"
```
When an eligible notification originates in the background or a non-visible tab, ashell shows a
native notification and a flashing bell on the corresponding tab. Unread notifications also add a
red badge to the macOS Dock icon or a red overlay to the Windows taskbar icon. Clicking the native
notification activates ashell and switches to the originating terminal tab; viewing that tab clears
its unread state. No reminder is shown when ashell is active and the notification originates in the
currently visible tab. An unselected tab shows a loading indicator beside its title for two seconds
after receiving terminal output. Continued output refreshes the indicator, while the selected tab
does not show it.
You can test a generic OSC 9 notification with:
```bash
printf '\033]9;Task completed\007'
```
## Package macOS App
```bash
+51 -3
View File
@@ -20,7 +20,11 @@ v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能
## 下载
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载最新安装包:
- macOS:x64 和 arm64 的 `.dmg`,同时提供便携 `.zip`。
- Windows:x64、x86 和 arm64 的 `Setup.exe`,同时提供便携 `.zip`。
- Linux:x64 便携 `.tar.gz`。
## Mac 安装指南
@@ -43,14 +47,27 @@ brew upgrade ashell --cask
### 方法 2: 手动下载
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载并解压。
2. 将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载与处理器匹配的 `.dmg`。
2. 打开 DMG,将 `ashell.app` 拖入 **Applications** 目录。
3. 由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
```bash
sudo xattr -cr /Applications/ashell.app
```
## Windows 安装指南
从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载对应安装程序:
| 系统架构 | 安装包名称后缀 |
| --- | --- |
| Intel/AMD 64 位 | `windows-x64-setup.exe` |
| Intel/AMD 32 位 | `windows-x86-setup.exe` |
| Windows on ARM | `windows-arm64-setup.exe` |
运行安装程序后,可选择是否创建桌面快捷方式;应用也会出现在开始菜单和系统卸载列表中。
当前安装包尚未使用商业代码签名证书,Windows SmartScreen 可能显示安全提醒。
## 功能特性
当前版本提供了一个功能完备的 GPUI 原生工作区:
@@ -73,6 +90,37 @@ sudo xattr -cr /Applications/ashell.app
cargo run --release
```
## 终端通知与任务提醒
ashell 支持 [OSC 9](https://iterm2.com/documentation-escape-codes.html)、
[OSC 99](https://sw.kovidgoyal.net/kitty/desktop-notifications/)、
[OSC 777](https://wezterm.org/escape-sequences.html) 和终端响铃(BEL),通知能力不绑定
Codex 或其它特定工具。只要 CLI、脚本或远程程序发送受支持的终端控制序列,本地终端、
SSH 和串口标签页都会将其转换为 macOS 或 Windows 系统通知,无需修改工具配置或伪装
其它终端。
OSC 9 携带通知正文;OSC 777 和 OSC 99 可以同时携带标题与正文,OSC 99 还支持
`always`、`unfocused` 和 `invisible` 显示时机。BEL 本身不含正文,因此仅显示通用提醒,
不会读取或推断当前终端画面中的内容。
符合显示时机的通知来自后台或非当前可见标签时,ashell 会显示系统通知,标签按钮以
闪动铃铛标记未读状态,并在 macOS Dock 或 Windows 任务栏图标上显示红色徽标。点击
系统通知会激活 ashell 并切换到对应的终端标签,查看对应标签后自动清除未读状态。
如果应用已激活且通知来自当前可见标签,则不重复提醒。未选中的标签在最近 2 秒内收到
终端输出时,会在标题左侧显示 Loading 动画;持续输出会刷新显示时间,当前标签不会显示。
可以分别使用下面的命令测试四种提醒:
```bash
sleep 3; printf '\033]9;Task completed\007'
sleep 3; printf '\033]777;notify;Deploy;Production is ready\033\\'
sleep 3; printf '\033]99;i=ashell-test:d=0;Build completed\033\\'; printf '\033]99;i=ashell-test:p=body;Artifacts are ready\033\\'
sleep 3; printf '\007'
```
执行后在 3 秒内切换到其他应用。macOS 第一次收到通知时可能会询问权限;如果仍未显示,
请在“系统设置 > 通知 > ashell”中确认已允许通知。
## 打包 macOS 应用
```bash
+185 -24
View File
@@ -1,7 +1,21 @@
remote_files: "REMOTE FILES"
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
sftp_connecting: "sftp connecting..."
refresh: "Refresh"
sftp_more_actions: "More remote file actions"
sftp_more_actions_short: "More"
reveal_current_directory: "Reveal current directory"
collapse_all_directories: "Collapse all directories"
parent_directory: "Parent directory"
create_or_upload: "Create or upload"
add: "Add"
directories: "DIRECTORIES"
expand_directory: "Expand directory"
collapse_directory: "Collapse directory"
retry_directory: "Retry loading directory"
loading_directory: "Loading directory..."
directory_load_failed: "Failed to load directory: %{reason}"
empty_directory: "This directory is empty"
selected_items: "%{count} selected"
upload_file: "Upload File"
upload_folder: "Upload Folder"
hidden: "Hidden"
@@ -11,9 +25,35 @@ download_count: "Download (%{count})"
name: "NAME"
size: "SIZE"
modified: "MODIFIED"
new_ssh_connection: "New SSH Connection"
new_connection: "New Connection"
edit_connection: "Edit Connection"
create_or_edit_ssh_session: "Create or edit an SSH session"
new_serial_connection: "New Serial Connection"
edit_serial_connection: "Edit Serial Connection"
serial_port: "Serial Port Path"
baud_rate: "Baud Rate"
session_name: "Session Name"
connection_group: "Group"
connection_group_name: "Group name"
new_connection_group: "New Group"
rename_connection_group: "Rename Group"
delete_connection_group: "Delete Group"
ungrouped: "Ungrouped"
move_to_group: "Move"
group_name_required: "Group name is required."
group_name_exists: "A group with this name already exists."
connection_group_created: "Created group %{name}"
connection_group_renamed: "Renamed group to %{name}"
connection_group_deleted: "Deleted group and moved %{count} connections to Ungrouped"
connections_moved_to_group: "Moved %{count} connections to %{name}"
new_folder: "New Folder"
rename: "Rename"
new_name: "New name"
name_required: "Name is required."
invalid_remote_name: "Name cannot be '.', '..', or contain '/'."
rename_success: "Renamed to %{name}"
rename_failed: "Rename failed: %{err}"
sftp_connection_unavailable: "The SFTP connection is unavailable."
create_folder_success: "Created folder: %{name}"
create_folder_failed: "Failed to create folder: %{err}"
delete_selected: "Delete"
@@ -23,21 +63,23 @@ system_path_warning: "WARNING: You are attempting to delete critical system path
delete_success: "Successfully deleted %{count} items"
delete_failed: "Failed to delete: %{err}"
deleting_paths: "Deleting %{count} items..."
confirm: "Confirm"
local_terminal: "Local Terminal"
open_local_shell_tab: "Open a new local shell tab"
connect: "Connect"
edit: "Edit"
done: "Done"
delete: "Delete"
host: "Host"
port: "Port"
user: "User"
password: "Password"
key: "Key"
private_key_path: "Private Key Path"
private_key_data: "Private Key Data"
save_and_connect: "Save & Connect"
ssh_config: "Config(~/.ssh/config)"
ssh_config_empty: "No entries found in ~/.ssh/config"
save: "Save"
cancel: "Cancel"
more: "More"
host_and_user_required: "Host and user are required"
language: "Language"
follow_system: "Follow System"
@@ -47,18 +89,73 @@ use_light_mode: "Use Light Mode"
use_dark_mode: "Use Dark Mode"
english: "English"
chinese: "简体中文"
idle_no_session: "idle / No session"
system: "SYSTEM"
cpu: "CPU"
mem: "MEM"
swap: "SWAP"
net: "NET"
disk: "DISK"
global_load: "global load"
live: "Live"
idle: "idle"
saved: "SAVED"
add_ssh: "+ ssh"
system_info: "SYSTEM"
memory: "Memory"
cpu_percent: "CPU"
memory_usage_short: "Memory"
process: "Process"
system_processes: "System Processes"
filter_processes: "Filter processes"
refresh_processes: "Refresh process list"
no_processes: "No process data"
no_matching_processes: "No matching processes"
process_details: "Process details"
copy_process: "Copy process information"
process_pid: "PID"
process_command: "Command"
process_summary: "%{name} · PID %{pid}"
network_ports: "Network Ports"
refresh_ports: "Refresh port list"
no_ports: "No network ports"
filter_ports: "Filter ports"
no_matching_ports: "No matching ports"
loading_ports: "Loading port list..."
protocol: "Protocol"
address: "Address"
state: "State"
loading_processes: "Loading process list..."
terminate_process: "Terminate Process"
confirm_terminate_process: "Terminate Process?"
confirm_terminate_process_desc: "Send a termination signal to PID %{pid}? Unsaved data may be lost."
terminating_process: "Sending termination signal to process %{pid}..."
process_terminated: "Termination signal sent to process %{pid}"
process_terminate_failed: "Failed to terminate process %{pid}: %{reason}"
connection_management: "CONNECTIONS"
manage_connections: "Manage connections"
import_connections: "Import"
export_connections: "Export"
import_export_connections: "Import & Export Connections"
new_connection_short: "New"
open_connection: "Open Connection"
filter_connections: "Filter connections"
no_connections: "No connections"
no_matching_connections: "No matching connections"
connections_exported: "Exported %{count} connections"
connections_import_summary: "Import complete: %{added} added, %{updated} updated, %{skipped} skipped"
connections_export_failed: "Export failed: %{reason}"
connections_import_failed: "Import failed: %{reason}"
connections_export_no_ssh: "No SSH connections are available to export"
connections_export_plaintext_title: "Export plaintext passwords?"
connections_export_plaintext_warning: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included. Store it securely and remove it after migration."
connections_export_plaintext_warning_with_skipped: "The CSV will contain passwords for %{count} SSH connections in plaintext. Private keys and passphrases are not included, and %{skipped} non-SSH connections will not be exported. Store it securely and remove it after migration."
delete_selected_connections: "Delete"
connections_deleted: "Deleted %{count} connections"
delete_selected_commands: "Delete selected commands"
commands_deleted: "Deleted %{count} commands"
command_history: "Command History"
command_history_short: "History"
command_history_empty: "No command history"
filter_command_history: "Filter command history"
no_matching_commands: "No matching commands"
copy_command: "Copy command"
execute_command: "Execute command"
close_command_history: "Close command history"
open_local_or_ssh: "Open a local terminal or pick an SSH session."
open_session: "Open Session"
settings: "Settings"
@@ -82,13 +179,15 @@ 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"
session_disconnected: "Session disconnected: %{reason}"
press_enter_to_reconnect: "Press Enter to reconnect"
ssh_reconnect_pending: "Waiting for reconnect confirmation"
reconnect_ssh_title: "Reconnect"
reconnect_ssh_desc: "%{name} is not connected. Reconnect now?"
reconnect_ssh: "Reconnect"
preview_failed: "Preview failed: %{err}"
downloading_file: "Downloading %{base}..."
@@ -114,8 +213,8 @@ no_transfers_yet: "No transfers yet"
downloading: "Downloading"
paused: "Paused"
completed: "Completed"
terminal_attention_required: "Terminal requires attention"
failed: "Failed"
cancelled: "Cancelled"
interrupted: "Interrupted"
session: "Session"
zombie: "Zombie"
@@ -125,46 +224,105 @@ software_builtin: "Built-in"
retry: "Retry"
right_click_copy_paste: "Right-click Copy/Paste"
keyword_highlight: "Terminal Keyword Highlighting"
remember_tabs: "Remember Tabs"
lock_layout: "Lock Window Layout"
lock_layout_hint: "Lock the position of the sidebar and the SFTP panel."
copy_paste_hint: "Selected text can also be copied by left-clicking."
settings_copy: "Copy Selection"
settings_paste: "Paste Clipboard"
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}"
edit_file_tooltip: "Edit directly in ashell and save changes to the remote server."
terminal_encoding: "Terminal Encoding"
local_terminal_encoding: "Local Terminal Encoding"
local_terminal_encoding_changed: "Local terminal encoding changed to %{encoding}."
default_local_terminal: "Default Local Terminal"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "Command Prompt"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "Not installed"
local_terminal_shell_fallback: "The selected local terminal is unavailable; switched back to %{terminal}."
local_terminal_shell_changed: "Default local terminal changed to %{terminal}; new terminals will use it."
file_encoding: "File Encoding"
terminal_encoding_changed: "Terminal encoding changed to %{encoding}; new input and output will use it."
file_encoding_changed: "File encoding changed to %{encoding}."
encoding_decode_warning: "Some bytes are invalid in %{encoding}. Try another encoding."
encoding_encode_failed: "Some characters cannot be represented in %{encoding}. Choose another encoding before saving."
loading_file_content: "Loading file content..."
saving_file_content: "Saving file..."
editor_content_too_large: "Content exceeds the %{max} in-app editing limit."
unsaved_changes: "Unsaved changes"
file_content_loaded: "File loaded"
open_remote_file_failed: "Failed to open remote file: %{err}"
remote_file_saved: "Remote file saved"
save_remote_file_failed: "Failed to save remote file: %{err}"
sync_cwd_tooltip: "Sync path from active terminal"
sync_cwd: "Sync Path"
scroll: "Scroll ↓"
clone: "Clone"
about_feedback_hint: "If you encounter a problem or have a suggestion, please report it on our GitHub project."
about_description: "A GPUI Component based SSH and local terminal client"
about_tips_title: "Shortcuts & Usage Tips:"
about_tip_backspace: "Unbinding Shortcuts: Press Backspace while recording a shortcut in Settings -> Keybindings to clear/unbind it."
about_tip_close_shortcuts: "Default Close Shortcuts: macOS uses ⌘+Q (Quit App) / ⌘+W (Close Tab); Windows/Linux uses Alt+F4 or Ctrl+Q (Quit App) / Ctrl+W (Close Tab)."
about_tip_conflict_shortcuts: "Potential Shortcut Conflicts: On Linux/Windows, default Ctrl shortcuts (e.g. Ctrl+W to close tab, Ctrl+L to focus pane) will intercept terminal Shell bindings (e.g. Ctrl+W to backward-kill-word, Ctrl+L to clear screen). Rebind to Ctrl+Shift+W or press Backspace to unbind in Settings -> Keybindings."
version: "Version"
close: "Close"
menu_about_ashell: "About Ashell"
menu_services: "Services"
menu_hide_ashell: "Hide Ashell"
menu_hide_others: "Hide Others"
menu_show_all: "Show All"
menu_quit_ashell: "Quit Ashell"
menu_file: "File"
menu_edit: "Edit"
menu_view: "View"
menu_help: "Help"
menu_close_window: "Close Window"
menu_undo: "Undo"
menu_redo: "Redo"
menu_cut: "Cut"
menu_copy: "Copy"
menu_paste: "Paste"
menu_select_all: "Select All"
menu_toggle_sidebar: "Toggle Sidebar"
menu_toggle_full_screen: "Enter Full Screen"
menu_minimize: "Minimize"
menu_zoom: "Zoom"
menu_project_website: "Ashell Project Website"
settings_general: "General"
settings_key_bindings: "Key Bindings"
settings_help: "Help"
settings_about: "About"
settings_group_appearance: "Appearance"
settings_group_font: "Font"
settings_group_terminal: "Terminal"
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"
none: "None"
settings_open_settings: "Settings"
settings_open_session: "Open Session"
settings_tab_list: "Tab List"
settings_open_transfers: "Open Transfer History"
files_downloading: "%{count} files downloading"
files_uploading: "%{count} files uploading"
panel_minimize: "Minimize Panel"
panel_minimize_short: "Collapse"
panel_expand: "Expand Panel"
settings_new_ssh: "New SSH"
panel_expand_short: "Expand"
settings_new_connection: "New Connection"
settings_open_search: "Search"
settings_quit_application: "Quit Application"
search: "Search"
no_results: "No results"
settings_toggle_sidebar: "Toggle Sidebar"
settings_toggle_sftp_zoom: "Toggle SFTP Zoom"
settings_toggle_sftp_zoom: "Toggle SFTP Panel Size"
settings_focus_pane_left: "Focus Pane Left"
settings_focus_pane_right: "Focus Pane Right"
settings_focus_pane_up: "Focus Pane Up"
@@ -174,7 +332,7 @@ 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'
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"
@@ -205,9 +363,12 @@ enable_proxy: "Enable Proxy"
read_env_proxy: "Read environment variables on startup"
read_env_proxy_desc: "If enabled, the global proxy settings below will be overridden by environment variables on startup."
global_proxy_settings: "Global Proxy Configuration"
global_proxy_type: "Proxy Type"
global_proxy_host: "Proxy Host"
global_proxy_port: "Proxy Port"
global_proxy_user: "Proxy Username"
global_proxy_password: "Proxy Password"
save_proxy: "Save Proxy"
settings_config_file: "Config File"
settings_backup_local_desc: "Export or import your unencrypted configuration file locally."
backup_export: "Export Config"
backup_import: "Import Config"
+187 -28
View File
@@ -1,7 +1,21 @@
remote_files: "远程文件"
open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。"
open_ssh_tab_sftp: "打开 SSH 标签页以浏览 SFTP 文件。"
sftp_connecting: "正在连接 SFTP..."
refresh: "刷新"
sftp_more_actions: "更多远程文件操作"
sftp_more_actions_short: "更多"
reveal_current_directory: "定位当前目录"
collapse_all_directories: "折叠全部目录"
parent_directory: "返回上级目录"
create_or_upload: "新建或上传"
add: "添加"
directories: "目录"
expand_directory: "展开目录"
collapse_directory: "折叠目录"
retry_directory: "重新加载目录"
loading_directory: "正在加载目录..."
directory_load_failed: "目录加载失败:%{reason}"
empty_directory: "当前目录为空"
selected_items: "已选择 %{count} 项"
upload_file: "上传文件"
upload_folder: "上传文件夹"
hidden: "显示隐藏文件"
@@ -11,9 +25,35 @@ download_count: "下载 (%{count})"
name: "名称"
size: "大小"
modified: "修改时间"
new_ssh_connection: "新建 SSH 连接"
new_connection: "新建连接"
edit_connection: "编辑连接"
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
new_serial_connection: "新建串口连接"
edit_serial_connection: "编辑串口连接"
serial_port: "串口设备路径"
baud_rate: "波特率"
session_name: "会话名称"
connection_group: "分组"
connection_group_name: "分组名称"
new_connection_group: "新建分组"
rename_connection_group: "重命名分组"
delete_connection_group: "删除分组"
ungrouped: "未分组"
move_to_group: "移动"
group_name_required: "分组名称不能为空。"
group_name_exists: "已存在同名分组。"
connection_group_created: "已创建分组 %{name}"
connection_group_renamed: "已重命名分组为 %{name}"
connection_group_deleted: "已删除分组,并将 %{count} 个连接移至未分组"
connections_moved_to_group: "已将 %{count} 个连接移动到 %{name}"
new_folder: "新建文件夹"
rename: "重命名"
new_name: "新名称"
name_required: "名称不能为空。"
invalid_remote_name: "名称不能是“.”、“..”或包含“/”。"
rename_success: "已重命名为 %{name}"
rename_failed: "重命名失败:%{err}"
sftp_connection_unavailable: "SFTP 连接不可用。"
create_folder_success: "成功创建文件夹: %{name}"
create_folder_failed: "创建文件夹失败: %{err}"
delete_selected: "删除"
@@ -25,20 +65,21 @@ delete_failed: "删除失败: %{err}"
deleting_paths: "正在删除 %{count} 个项..."
confirm: "确认"
local_terminal: "本地终端"
open_local_shell_tab: "打开一个新的本地终端页面"
open_local_shell_tab: "打开一个新的本地终端标签页"
connect: "连接"
edit: "编辑"
done: "完成"
delete: "删除"
host: "主机 (Host)"
port: "端口 (Port)"
user: "用户 (User)"
password: "密码 (Password)"
key: "密钥 (Key)"
private_key_path: "私钥路径"
private_key_data: "私钥内容"
save_and_connect: "保存并连接"
ssh_config: "配置(~/.ssh/config)"
ssh_config_empty: "~/.ssh/config 中未找到条目"
save: "保存"
cancel: "取消"
more: "更多"
host_and_user_required: "主机名和用户名不能为空"
language: "显示语言"
follow_system: "跟随系统"
@@ -48,18 +89,73 @@ use_light_mode: "使用浅色模式"
use_dark_mode: "使用深色模式"
english: "English"
chinese: "简体中文"
idle_no_session: "空闲 / 未连接会话"
system: "系统"
cpu: "CPU"
mem: "内存"
swap: "虚拟内存"
net: "网络"
disk: "磁盘"
global_load: "全局负载"
live: "实时"
idle: "空闲"
saved: "已保存会话"
add_ssh: "+ SSH"
system_info: "系统信息"
memory: "内存"
cpu_percent: "CPU"
memory_usage_short: "内存"
process: "进程"
system_processes: "系统进程"
filter_processes: "筛选进程"
refresh_processes: "刷新进程列表"
no_processes: "暂无进程数据"
no_matching_processes: "没有匹配的进程"
process_details: "进程详细信息"
copy_process: "复制进程信息"
process_pid: "PID"
process_command: "命令"
process_summary: "%{name} · PID %{pid}"
network_ports: "网络端口"
refresh_ports: "刷新端口列表"
no_ports: "暂无网络端口"
filter_ports: "筛选端口"
no_matching_ports: "没有匹配的端口"
loading_ports: "正在加载端口列表..."
protocol: "协议"
address: "地址"
state: "状态"
loading_processes: "正在加载进程列表..."
terminate_process: "终止进程"
confirm_terminate_process: "确认终止进程?"
confirm_terminate_process_desc: "确定要向 PID %{pid} 发送终止信号吗?未保存的数据可能会丢失。"
terminating_process: "正在向进程 %{pid} 发送终止信号..."
process_terminated: "已向进程 %{pid} 发送终止信号"
process_terminate_failed: "终止进程 %{pid} 失败:%{reason}"
connection_management: "连接"
manage_connections: "管理连接"
import_connections: "导入连接"
export_connections: "导出连接"
import_export_connections: "导入与导出连接"
new_connection_short: "新建"
open_connection: "打开连接"
filter_connections: "筛选连接"
no_connections: "暂无连接"
no_matching_connections: "没有匹配的连接"
connections_exported: "已导出 %{count} 个连接"
connections_import_summary: "导入完成:新增 %{added} 个,更新 %{updated} 个,跳过 %{skipped} 个"
connections_export_failed: "导出失败:%{reason}"
connections_import_failed: "导入失败:%{reason}"
connections_export_no_ssh: "没有可导出的 SSH 连接"
connections_export_plaintext_title: "导出明文密码?"
connections_export_plaintext_warning: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令。请妥善保管,并在迁移后及时移除。"
connections_export_plaintext_warning_with_skipped: "CSV 将以明文保存 %{count} 个 SSH 连接的密码,不包含私钥和私钥口令;另有 %{skipped} 个非 SSH 连接不会导出。请妥善保管,并在迁移后及时移除。"
delete_selected_connections: "删除"
connections_deleted: "已删除 %{count} 个连接"
delete_selected_commands: "删除选中命令"
commands_deleted: "已删除 %{count} 条命令"
command_history: "历史命令"
command_history_short: "历史"
command_history_empty: "暂无历史命令"
filter_command_history: "筛选历史命令"
no_matching_commands: "没有匹配的命令"
copy_command: "复制命令"
execute_command: "执行命令"
close_command_history: "关闭历史命令"
open_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
open_session: "打开会话"
settings: "设置"
@@ -79,17 +175,19 @@ cursor_style_default: "默认"
cursor_style_blink: "闪烁"
cursor_style_beam: "细线"
cursor_style_beam_blink: "细线闪烁"
monitoring_position: "系统监控显示位置"
monitoring_position: "监控显示位置"
position_bottom: "底部"
position_sidebar: "侧边栏"
position_hidden: "不显示"
local_host: "本地主机"
local_terminal_opened: "已打开本地终端"
connecting: "正在连接"
starting_connection: "开始连接..."
connection_failed: "连接失败"
session_disconnected: "会话已断开: %{reason}"
press_enter_to_reconnect: "按 Enter 重新连接"
ssh_reconnect_pending: "等待确认后重新连接"
reconnect_ssh_title: "重新连接"
reconnect_ssh_desc: "%{name} 当前未连接,是否现在重新连接?"
reconnect_ssh: "重新连接"
preview_failed: "预览失败: %{err}"
downloading_file: "正在下载 %{base}..."
@@ -115,8 +213,8 @@ no_transfers_yet: "暂无传输记录"
downloading: "正在下载"
paused: "已暂停"
completed: "已完成"
terminal_attention_required: "终端中有需要关注的新状态"
failed: "失败"
cancelled: "已取消"
interrupted: "已中断"
session: "会话"
zombie: "僵尸任务"
@@ -126,47 +224,105 @@ software_builtin: "软件内置"
retry: "重试"
right_click_copy_paste: "右键复制/粘贴"
keyword_highlight: "终端关键词高亮"
remember_tabs: "记忆标签页"
lock_layout: "锁定窗口布局"
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
copy_paste_hint: "选中后左键点击也可复制。"
settings_copy: "复制选区"
settings_paste: "粘贴剪贴板"
edit_file: "编辑文件"
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服务器。"
terminal_encoding: "终端编码"
local_terminal_encoding: "本地终端编码"
local_terminal_encoding_changed: "本地终端编码已切换为 %{encoding}。"
default_local_terminal: "默认本地终端"
local_terminal_windows_powershell: "Windows PowerShell"
local_terminal_powershell_7: "PowerShell 7"
local_terminal_cmd: "命令提示符"
local_terminal_git_bash: "Git Bash"
local_terminal_not_installed: "未安装"
local_terminal_shell_fallback: "所选本地终端不可用,已改回 %{terminal}。"
local_terminal_shell_changed: "默认本地终端已切换为 %{terminal},新终端生效。"
file_encoding: "文件编码"
terminal_encoding_changed: "终端编码已切换为 %{encoding},新的输入和输出将使用此编码。"
file_encoding_changed: "文件编码已切换为 %{encoding}。"
encoding_decode_warning: "部分字节无法使用 %{encoding} 正确解码,请尝试其它编码。"
encoding_encode_failed: "部分字符无法使用 %{encoding} 表示,请更换编码后再保存。"
loading_file_content: "正在加载文件内容..."
saving_file_content: "正在保存文件..."
editor_content_too_large: "内容超过应用内编辑器的 %{max} 限制。"
unsaved_changes: "有未保存的修改"
file_content_loaded: "文件已加载"
open_remote_file_failed: "打开远程文件失败:%{err}"
remote_file_saved: "远程文件已保存"
save_remote_file_failed: "保存远程文件失败:%{err}"
sync_cwd_tooltip: "从当前终端同步路径"
sync_cwd: "同步路径"
scroll: "滑动查看 ↓"
clone: "克隆"
about_feedback_hint: "如果遇到问题或者有什么建议,请前往我们的 GitHub 项目中反馈。"
about_description: "基于 GPUI Component 的 SSH 与本地终端客户端"
about_tips_title: "快捷键与使用提示:"
about_tip_backspace: "快捷键解绑:在“设置 -> 快捷键”中录制按键时按 Backspace (退格键) 即可置空/解绑该快捷键。"
about_tip_close_shortcuts: "默认关闭快捷键:macOS 为 ⌘+Q (退出应用) / ⌘+W (关闭标签);Windows / Linux 为 Alt+F4 或 Ctrl+Q (退出应用) / Ctrl+W (关闭标签)。"
about_tip_conflict_shortcuts: "潜在快捷键冲突:Linux / Windows 默认修饰键为 Ctrl,应用默认快捷键(如 Ctrl+W 关闭标签、Ctrl+L 切换窗格等)会挡住终端 Shell 的原生快捷键(如 Bash/Zsh 中 Ctrl+W 清除单词、Ctrl+L 清屏)。建议在“设置 -> 快捷键”中改为 Ctrl+Shift+W 或按 Backspace 置空解绑。"
version: "版本"
close: "关闭"
menu_about_ashell: "关于 Ashell"
menu_services: "服务"
menu_hide_ashell: "隐藏 Ashell"
menu_hide_others: "隐藏其它应用"
menu_show_all: "全部显示"
menu_quit_ashell: "退出 Ashell"
menu_file: "文件"
menu_edit: "编辑"
menu_view: "视图"
menu_help: "帮助"
menu_close_window: "关闭窗口"
menu_undo: "撤销"
menu_redo: "重做"
menu_cut: "剪切"
menu_copy: "复制"
menu_paste: "粘贴"
menu_select_all: "全选"
menu_toggle_sidebar: "切换侧边栏"
menu_toggle_full_screen: "进入全屏"
menu_minimize: "最小化"
menu_zoom: "缩放"
menu_project_website: "Ashell 项目主页"
settings_general: "常规"
settings_key_bindings: "快捷键"
settings_help: "帮助"
settings_about: "关于"
settings_group_appearance: "外观"
settings_group_font: "字体"
settings_group_terminal: "终端"
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: "打开设置"
none: "无"
settings_open_settings: "设置"
settings_open_session: "打开会话"
settings_tab_list: "标签列表"
settings_open_transfers: "打开传输历史"
files_downloading: "%{count} 个文件正在下载"
files_uploading: "%{count} 个文件正在上传"
panel_minimize: "折叠面板"
panel_minimize_short: "折叠"
panel_expand: "展开面板"
settings_new_ssh: "新建 SSH"
panel_expand_short: "展开"
settings_new_connection: "新建连接"
settings_open_search: "搜索"
settings_quit_application: "退出应用"
search: "搜索"
no_results: "无结果"
settings_toggle_sidebar: "缩放侧边栏"
settings_toggle_sftp_zoom: "缩放 SFTP 面板"
settings_toggle_sidebar: "切换侧边栏"
settings_toggle_sftp_zoom: "切换 SFTP 面板大小"
settings_focus_pane_left: "向左切换焦点"
settings_focus_pane_right: "向右切换焦点"
settings_focus_pane_up: "向上切换焦点"
@@ -176,7 +332,7 @@ settings_split_pane_right: "向右拆分面板"
settings_split_pane_up: "向上拆分面板"
settings_split_pane_down: "向下拆分面板"
settings_close_pane: "关闭当前面板"
keybind_conflict: '"%{key}" 已被「%{action}」使用,请选择其他快捷键'
keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键"
title_bar_style: "标题栏样式"
title_bar_native: "原生"
title_bar_integrated: "融合"
@@ -207,9 +363,12 @@ enable_proxy: "启用代理功能"
read_env_proxy: "启动时读取环境变量"
read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。"
global_proxy_settings: "全局代理配置"
global_proxy_type: "代理类型"
global_proxy_host: "代理地址"
global_proxy_port: "代理端口"
global_proxy_user: "代理用户名"
global_proxy_password: "代理密码"
save_proxy: "保存代理设置"
settings_config_file: "配置文件"
settings_backup_local_desc: "在本地导出或导入你未加密的配置文件。"
backup_export: "导出配置"
backup_import: "导入配置"
+417
View File
@@ -0,0 +1,417 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintainer: Zhenghan Yang (Kira)
; Email: 847320916@QQ.com
; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; Encoding: UTF-8
; Translation based on network resource
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; LanguageCodePage should always be set if possible, even if this file is Unicode
; For English it's set to zero anyway because English only uses ASCII characters
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** Application titles
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** Startup error messages
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序已在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。
SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** Startup questions
PrivilegesRequiredOverrideTitle=选择安装程序安装模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐)
; *** Misc. errors
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。
; *** Setup common messages
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** Buttons
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** "Select Language" dialog messages
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** Common wizard text
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** "Welcome" wizard page
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** "Password" wizard page
WizardPassword=密码
PasswordLabel1=此安装程序需要密码验证。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** "License Agreement" wizard page
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** "Information" wizard pages
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** "User Information" wizard page
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=请输入用户名。
; *** "Select Destination Location" wizard page
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** "Select Components" wizard page
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** "Select Additional Tasks" wizard page
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** "Select Start Menu Folder" wizard page
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名称。
GroupNameTooLong=文件夹名称或路径太长。
InvalidGroupName=文件夹名称无效。
BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** "Ready to Install" wizard page
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TDownloadWizardPage wizard page and DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止。
ErrorDownloadFailed=下载失败:%1 %2。
ErrorDownloadSizeFailed=获取大小失败:%1 %2。
ErrorProgress=无效的进度:%1 / %2。
ErrorFileSize=文件大小错误:预期 %1,实际 %2。
; *** TExtractionWizardPage wizard page and ExtractArchive
ExtractingLabel=正在提取文件...
ButtonStopExtraction=停止提取(&S)
StopExtraction=您确定要停止提取吗?
ErrorExtractionAborted=提取已中止。
ErrorExtractionFailed=提取失败:%1
; *** Archive extraction failure details
ArchiveIncorrectPassword=密码不正确。
ArchiveIsCorrupted=压缩包已损坏。
ArchiveUnsupportedFormat=不支持的压缩包格式。
; *** "Preparing to Install" wizard page
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗?
; *** "Installing" wizard page
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。
; *** "Setup Completed" wizard page
FinishedHeadingLabel=完成 [name] 安装向导
FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。
FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启计算机(&Y)
NoRadio=否,稍后重启计算机(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** "Setup Needs the Next Disk" stuff
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** Installation phase messages
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=取消安装
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消
; *** Installation status messages
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** Misc. errors
ErrorInternal2=内部错误:%1。
ErrorFunctionFailedNoCode=%1 失败。
ErrorFunctionFailed=%1 失败;错误代码 %2。
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** Registry errors
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI errors
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** File copying errors
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐)
SourceIsCorrupted=源文件已损坏。
SourceDoesntExist=源文件“%1”不存在。
SourceVerificationFailed=源文件验证失败:%1
VerificationSignatureDoesntExist=签名文件“%1”不存在。
VerificationSignatureInvalid=签名文件“%1”无效。
VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。
VerificationFileNameIncorrect=文件名不正确。
VerificationFileTagIncorrect=文件标签不正确。
VerificationFileSizeIncorrect=文件大小不正确。
VerificationFileHashIncorrect=文件哈希值不正确。
ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K)
ErrorReadingExistingDest=尝试读取已存在的文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留已存在的文件(&K)
FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐)
ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列已存在的文件属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=尝试下载文件时出错:
ErrorExtracting=尝试提取压缩包时出错:
ErrorReplacingExistingFile=尝试替换已存在的文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。
ErrorRegisterTypeLib=无法注册类型库:%1
; *** Uninstall display name markings
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** Post-installation errors
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。
; *** Uninstaller messages
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。
UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。
UninstalledAll=已顺利从您的计算机中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载。
; *** Uninstallation phase messages
ConfirmDeleteSharedFileTitle=删除共享文件?
ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗?
+82
View File
@@ -0,0 +1,82 @@
#define MyAppName "ashell"
#define MyAppUserModelId "dev.ashell.app"
#define MyAppVersion GetEnv("PACKAGE_VERSION")
#define MyAppArch GetEnv("ASHELL_ARCH")
#define MyAppExeSource GetEnv("ASHELL_BINARY_PATH")
#define MyOutputDir GetEnv("ASHELL_OUTPUT_DIR")
#define MyOutputBaseName GetEnv("PACKAGE_BASENAME")
#if MyAppVersion == ""
#error PACKAGE_VERSION is required
#endif
#if MyAppExeSource == ""
#error ASHELL_BINARY_PATH is required
#endif
#if MyOutputDir == ""
#error ASHELL_OUTPUT_DIR is required
#endif
#if MyOutputBaseName == ""
#error PACKAGE_BASENAME is required
#endif
#if MyAppArch == "x64"
#define MyAppId "dev.ashell.app.x64"
#elif MyAppArch == "x86"
#define MyAppId "dev.ashell.app.x86"
#elif MyAppArch == "arm64"
#define MyAppId "dev.ashell.app.arm64"
#else
#error Unsupported ASHELL_ARCH value
#endif
[Setup]
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher=ashell contributors
AppPublisherURL=https://github.com/rust-kotlin/ashell
AppSupportURL=https://github.com/rust-kotlin/ashell/issues
AppUpdatesURL=https://github.com/rust-kotlin/ashell/releases
DefaultDirName={localappdata}\Programs\ashell
DefaultGroupName=ashell
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
OutputDir={#MyOutputDir}
OutputBaseFilename={#MyOutputBaseName}-setup
SetupIconFile=..\..\assets\icons\ashell.ico
UninstallDisplayIcon={app}\ashell.exe
LicenseFile=..\..\LICENSE
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
MinVersion=10.0
CloseApplications=yes
RestartApplications=no
#if MyAppArch == "x64"
ArchitecturesAllowed=x64compatible and not arm64
ArchitecturesInstallIn64BitMode=x64compatible
#elif MyAppArch == "x86"
ArchitecturesAllowed=x86compatible and not x64compatible and not arm64
ArchitecturesInstallIn64BitMode=
#elif MyAppArch == "arm64"
ArchitecturesAllowed=arm64
ArchitecturesInstallIn64BitMode=arm64
#endif
[Languages]
Name: "en"; MessagesFile: "compiler:Default.isl"
Name: "zhcn"; MessagesFile: "ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#MyAppExeSource}"; DestDir: "{app}"; DestName: "ashell.exe"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\ashell"; Filename: "{app}\ashell.exe"; AppUserModelID: "{#MyAppUserModelId}"
Name: "{autodesktop}\ashell"; Filename: "{app}\ashell.exe"; AppUserModelID: "{#MyAppUserModelId}"; Tasks: desktopicon
[Run]
Filename: "{app}\ashell.exe"; Description: "{cm:LaunchProgram,ashell}"; Flags: nowait postinstall skipifsilent
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "stacksafe-macro"
version = "0.1.4"
edition = "2021"
rust-version = "1.80.0"
description = "Procedural macro implementation for the stacksafe crate."
license = "Apache-2.0"
repository = "https://github.com/fast/stacksafe"
publish = false
[lib]
proc-macro = true
[dependencies]
quote = "1"
syn = { version = "2", features = ["full"] }
+73
View File
@@ -0,0 +1,73 @@
// Copyright 2025 FastLabs Developers
// Licensed under the Apache License, Version 2.0.
//! Procedural macro implementation for the `stacksafe` crate.
//!
//! This local compatibility patch preserves the 0.1.4 API while replacing
//! the unmaintained `proc-macro-error2` dependency with native `syn` errors.
use proc_macro::TokenStream;
use quote::{quote, ToTokens};
use syn::{parse_macro_input, parse_quote, ItemFn, Path, ReturnType, Type};
#[proc_macro_attribute]
pub fn stacksafe(args: TokenStream, item: TokenStream) -> TokenStream {
let mut crate_path: Option<Path> = None;
let arg_parser = syn::meta::parser(|meta| {
if meta.path.is_ident("crate") {
crate_path = Some(meta.value()?.parse()?);
Ok(())
} else {
Err(meta.error(format!(
"unknown attribute parameter `{}`",
meta.path
.get_ident()
.map_or("unknown".to_string(), |ident| ident.to_string())
)))
}
});
parse_macro_input!(args with arg_parser);
let mut item_fn: ItemFn = match syn::parse(item) {
Ok(item_fn) => item_fn,
Err(error) => {
return syn::Error::new(
error.span(),
"#[stacksafe] can only be applied to functions",
)
.into_compile_error()
.into();
}
};
if let Some(asyncness) = &item_fn.sig.asyncness {
return syn::Error::new_spanned(asyncness, "#[stacksafe] does not support async functions")
.into_compile_error()
.into();
}
let block = item_fn.block;
let ret = match &item_fn.sig.output {
ReturnType::Type(_, ty) if matches!(**ty, Type::ImplTrait(_)) => ReturnType::Default,
_ => item_fn.sig.output.clone(),
};
let stacksafe_crate = crate_path.unwrap_or_else(|| parse_quote!(::stacksafe));
let wrapped_block = quote! {
{
#stacksafe_crate::internal::stacker::maybe_grow(
#stacksafe_crate::get_minimum_stack_size(),
#stacksafe_crate::get_stack_allocation_size(),
#stacksafe_crate::internal::with_protected(move || #ret { #block })
)
}
};
let wrapped_block = match syn::parse2(wrapped_block) {
Ok(block) => block,
Err(error) => return error.into_compile_error().into(),
};
item_fn.block = Box::new(wrapped_block);
item_fn.into_token_stream().into()
}
+9 -2
View File
@@ -9,6 +9,13 @@ CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
APP_VERSION="${APP_VERSION:-$(sed -n 's/^version = "\([^"]*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)}"
APP_BUILD_VERSION="${APP_BUILD_VERSION:-$APP_VERSION}"
if [[ -z "$APP_VERSION" ]]; then
echo "error: unable to read the package version from Cargo.toml" >&2
exit 1
fi
cd "$ROOT_DIR"
cargo build --release
@@ -40,9 +47,9 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$APP_VERSION</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$APP_BUILD_VERSION</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
+945 -1
View File
@@ -1,13 +1,446 @@
use std::{
collections::{HashMap, HashSet},
path::Path,
};
use anyhow::{Context as _, Result, anyhow, bail};
use csv::{ReaderBuilder, StringRecord, Terminator, WriterBuilder};
use gpui::{Context, Entity, SharedString};
use gpui_component::input::InputState;
use gpui_component::{WindowExt as _, dialog::DialogButtonProps, input::InputState};
use rust_i18n::t;
use serde::{Deserialize, Serialize};
use crate::{
Ashell,
app::DialogKind,
session::{
config::{AuthMethod, Session},
ssh_keys::normalize_inline_private_key,
},
sync::{self, SyncBackendCredentials, SyncCredentials, SyncPayload, SyncResult},
terminal::BackendEvent,
};
const CONNECTION_CSV_HEADERS: [&str; 6] = ["name", "group", "host", "port", "username", "password"];
#[derive(Debug, Serialize)]
struct ConnectionCsvExportRow<'a> {
name: &'a str,
group: &'a str,
host: &'a str,
port: u16,
username: &'a str,
password: &'a str,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
struct ConnectionCsvRow {
#[serde(default)]
name: String,
#[serde(default)]
group: String,
#[serde(default)]
host: String,
#[serde(default)]
port: String,
#[serde(default)]
username: String,
#[serde(default)]
auth_type: String,
#[serde(default)]
password: String,
#[serde(default)]
private_key_path: String,
#[serde(default)]
private_key_content: String,
#[serde(default)]
passphrase: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ConnectionCsvColumns {
name: bool,
group: bool,
auth_type: bool,
password: bool,
private_key_path: bool,
private_key_content: bool,
passphrase: bool,
}
#[derive(Debug)]
struct ImportedCsvConnection {
session: Session,
columns: ConnectionCsvColumns,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct ConnectionIdentity {
host: String,
port: u16,
username: String,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ConnectionImportSummary {
added: usize,
updated: usize,
skipped: usize,
}
fn normalize_csv_header(header: &str) -> String {
header
.trim_start_matches('\u{feff}')
.trim()
.to_lowercase()
.chars()
.map(|character| match character {
' ' | '-' => '_',
_ => character,
})
.collect()
}
fn canonical_csv_header(header: &str) -> Option<&'static str> {
match normalize_csv_header(header).as_str() {
"name" | "title" | "session" | "session_name" | "connection_name" => Some("name"),
"group" | "folder" | "category" | "connection_group" => Some("group"),
"host" | "hostname" | "host_name" | "ip" | "ip_address" | "address" | "server" => {
Some("host")
}
"port" | "ssh_port" => Some("port"),
"username" | "user" | "user_name" | "login" | "login_name" => Some("username"),
"auth_type"
| "auth"
| "authentication"
| "authentication_type"
| "auth_method"
| "method" => Some("auth_type"),
"password" | "pass" | "passwd" => Some("password"),
"private_key_path" | "key_path" | "identity_file" | "identityfile" | "privatekeypath" => {
Some("private_key_path")
}
"private_key_content"
| "key_content"
| "private_key"
| "privatekey"
| "private_key_data" => Some("private_key_content"),
"passphrase" | "key_password" | "key_passphrase" => Some("passphrase"),
_ => None,
}
}
fn parse_auth_method(value: &str, inferred: AuthMethod, line: u64) -> Result<AuthMethod> {
let normalized = normalize_csv_header(value);
match normalized.as_str() {
"" => Ok(inferred),
"password" | "pass" => Ok(AuthMethod::Password),
"key" | "private_key" | "public_key" => Ok(AuthMethod::Key),
"config" | "ssh_config" => Ok(AuthMethod::Config),
_ => bail!("CSV line {line}: unsupported auth_type '{value}'"),
}
}
impl ConnectionCsvColumns {
fn from_headers(headers: &HashSet<&'static str>) -> Self {
Self {
name: headers.contains("name"),
group: headers.contains("group"),
auth_type: headers.contains("auth_type"),
password: headers.contains("password"),
private_key_path: headers.contains("private_key_path"),
private_key_content: headers.contains("private_key_content"),
passphrase: headers.contains("passphrase"),
}
}
}
impl ConnectionCsvRow {
fn into_connection(
self,
line: u64,
columns: ConnectionCsvColumns,
) -> Result<ImportedCsvConnection> {
let Self {
name,
group,
host,
port,
username,
auth_type,
password,
private_key_path,
private_key_content,
passphrase,
} = self;
let host = host.trim().to_string();
let username = username.trim().to_string();
if host.is_empty() {
bail!("CSV line {line}: host is required");
}
if username.is_empty() {
bail!("CSV line {line}: username is required");
}
let port = if port.trim().is_empty() {
22
} else {
port.trim()
.parse::<u16>()
.with_context(|| format!("CSV line {line}: invalid port '{}'", port.trim()))?
};
if port == 0 {
bail!("CSV line {line}: port must be between 1 and 65535");
}
let private_key_path = private_key_path.trim().to_string();
let private_key_content = normalize_inline_private_key(&private_key_content);
let inferred_auth = if !private_key_path.is_empty()
|| !private_key_content.is_empty()
|| password.is_empty()
{
AuthMethod::Key
} else {
AuthMethod::Password
};
let auth = parse_auth_method(&auth_type, inferred_auth, line)?;
let mut session = Session::password(host, port, username, password);
session.group = group.trim().to_string();
session.auth = auth;
session.private_key_path = if private_key_content.is_empty() {
private_key_path
} else {
String::new()
};
session.private_key_inline = private_key_content;
session.passphrase = passphrase;
if !name.trim().is_empty() {
session.name = name.trim().to_string();
}
Ok(ImportedCsvConnection { session, columns })
}
}
fn decode_connection_csv(bytes: &[u8]) -> Result<Vec<ImportedCsvConnection>> {
std::str::from_utf8(bytes).context("connection CSV must be UTF-8")?;
let mut reader = ReaderBuilder::new()
.has_headers(true)
.flexible(true)
.trim(csv::Trim::Headers)
.from_reader(bytes);
let source_headers = reader
.headers()
.context("failed to read CSV header")?
.clone();
if source_headers.is_empty() {
bail!("connection CSV has no header");
}
let mut recognized_headers = HashSet::new();
let normalized_headers = source_headers
.iter()
.enumerate()
.map(|(index, header)| {
if let Some(canonical) = canonical_csv_header(header) {
if !recognized_headers.insert(canonical) {
bail!("connection CSV contains duplicate '{canonical}' columns");
}
Ok(canonical.to_string())
} else {
Ok(format!("__ignored_{index}"))
}
})
.collect::<Result<Vec<_>>>()?;
for required in ["host", "username"] {
if !recognized_headers.contains(required) {
bail!("connection CSV is missing the '{required}' column");
}
}
let columns = ConnectionCsvColumns::from_headers(&recognized_headers);
let normalized_headers = StringRecord::from(normalized_headers);
reader.set_headers(normalized_headers.clone());
let mut connections = Vec::new();
for (index, record) in reader.records().enumerate() {
let record = record.map_err(|err| {
let line = err
.position()
.map(|position| position.line())
.unwrap_or(index as u64 + 2);
anyhow!("CSV line {line}: {err}")
})?;
let line = index as u64 + 2;
let row = record
.deserialize::<ConnectionCsvRow>(Some(&normalized_headers))
.map_err(|err| anyhow!("CSV line {line}: {err}"))?;
connections.push(row.into_connection(line, columns)?);
}
Ok(connections)
}
fn encode_connection_csv(sessions: &[Session]) -> Result<Vec<u8>> {
let mut output = vec![0xef, 0xbb, 0xbf];
{
let mut writer = WriterBuilder::new()
.has_headers(false)
.terminator(Terminator::CRLF)
.from_writer(&mut output);
writer
.write_record(CONNECTION_CSV_HEADERS)
.context("failed to write CSV header")?;
for session in sessions {
let row = ConnectionCsvExportRow {
name: &session.name,
group: &session.group,
host: &session.host,
port: session.port,
username: &session.user,
password: if session.auth == AuthMethod::Password {
&session.password
} else {
""
},
};
writer
.serialize(row)
.context("failed to serialize connection CSV")?;
}
writer.flush().context("failed to finish connection CSV")?;
}
Ok(output)
}
fn write_connection_csv(path: &Path, contents: &[u8]) -> Result<()> {
#[cfg(unix)]
{
use std::{
io::Write as _,
os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _},
};
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
.with_context(|| format!("failed to open {}", path.display()))?;
file.write_all(contents)
.with_context(|| format!("failed to write {}", path.display()))?;
let mut permissions = file
.metadata()
.with_context(|| format!("failed to inspect {}", path.display()))?
.permissions();
permissions.set_mode(0o600);
file.set_permissions(permissions)
.with_context(|| format!("failed to protect {}", path.display()))?;
}
#[cfg(not(unix))]
std::fs::write(path, contents)
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn is_ssh_session(session: &Session) -> bool {
session.protocol.eq_ignore_ascii_case("ssh")
}
fn connection_identity(session: &Session) -> ConnectionIdentity {
ConnectionIdentity {
host: session.host.trim().to_lowercase(),
port: session.port,
username: session.user.trim().to_string(),
}
}
fn deduplicate_csv_connections(
connections: Vec<ImportedCsvConnection>,
) -> Vec<ImportedCsvConnection> {
let mut positions = HashMap::<ConnectionIdentity, usize>::new();
let mut deduplicated = Vec::new();
for connection in connections {
let identity = connection_identity(&connection.session);
if let Some(index) = positions.get(&identity).copied() {
deduplicated[index] = connection;
} else {
positions.insert(identity, deduplicated.len());
deduplicated.push(connection);
}
}
deduplicated
}
fn update_csv_session(existing: &mut Session, imported: ImportedCsvConnection) {
let ImportedCsvConnection { session, columns } = imported;
if columns.name {
existing.name = session.name;
}
if columns.group {
existing.group = session.group;
}
existing.host = session.host;
existing.port = session.port;
existing.user = session.user;
if columns.password
|| columns.auth_type
|| columns.private_key_path
|| columns.private_key_content
{
existing.auth = session.auth;
}
if columns.password {
existing.password = session.password;
}
if columns.private_key_content {
existing.private_key_inline = session.private_key_inline.clone();
if !session.private_key_inline.is_empty() {
existing.private_key_path.clear();
}
}
if columns.private_key_path && session.private_key_inline.is_empty() {
existing.private_key_path = session.private_key_path;
if !existing.private_key_path.is_empty() {
existing.private_key_inline.clear();
}
}
if columns.passphrase {
existing.passphrase = session.passphrase;
}
}
fn merge_csv_sessions(
local_sessions: &mut Vec<Session>,
imported_connections: Vec<ImportedCsvConnection>,
) -> ConnectionImportSummary {
let mut summary = ConnectionImportSummary::default();
for imported in deduplicate_csv_connections(imported_connections) {
let identity = connection_identity(&imported.session);
let mut matches = local_sessions
.iter()
.enumerate()
.filter(|(_, local)| is_ssh_session(local) && connection_identity(local) == identity)
.map(|(index, _)| index);
let first_match = matches.next();
if matches.next().is_some() {
summary.skipped += 1;
continue;
}
if let Some(index) = first_match {
update_csv_session(&mut local_sessions[index], imported);
summary.updated += 1;
continue;
}
local_sessions.push(imported.session);
summary.added += 1;
}
summary
}
impl Ashell {
fn sync_input_value(input: &Entity<InputState>, cx: &Context<Self>) -> String {
input.read(cx).value().trim().to_string()
@@ -126,4 +559,515 @@ impl Ashell {
let _ = events.send(BackendEvent::SyncFinished(result));
});
}
pub(crate) fn export_local_config(
&mut self,
window: &mut gpui::Window,
cx: &mut Context<Self>,
) {
let local_config = self.config.cache.clone();
let file_dialog = rfd::AsyncFileDialog::new()
.set_file_name("ashell-config.json")
.add_filter("JSON", &["json"])
.save_file();
cx.spawn_in(window, async move |_this, cx| {
if let Some(file_handle) = file_dialog.await {
let path = file_handle.path().to_path_buf();
if let Ok(json_str) = serde_json::to_string_pretty(&local_config) {
let _ = cx
.background_executor()
.spawn(async move {
if let Err(err) = std::fs::write(path, json_str) {
tracing::error!("failed to export local config: {err:#}");
}
})
.await;
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn import_local_config(
&mut self,
window: &mut gpui::Window,
cx: &mut Context<Self>,
) {
let file_dialog = rfd::AsyncFileDialog::new()
.add_filter("JSON", &["json"])
.pick_file();
cx.spawn_in(window, async move |this, cx| {
if let Some(file_handle) = file_dialog.await {
let path = file_handle.path().to_path_buf();
let read_result = cx
.background_executor()
.spawn(async move { std::fs::read_to_string(path) })
.await;
if let Ok(json_str) = read_result {
if let Ok(config_file) =
serde_json::from_str::<crate::session::config::ConfigFile>(&json_str)
{
let _ = gpui::AsyncWindowContext::update(cx, |window, cx| {
let _ = this.update(cx, |this, cx| {
this.config.cache = config_file;
this.config.normalize_command_history();
this.selected_connection_ids.clear();
if let Err(err) = this.config.save() {
tracing::error!("failed to save imported config: {err:#}");
} else {
this.apply_loaded_config(window, cx);
}
});
});
}
}
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn export_connections(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
let parent_dialog = self.active_dialog;
if parent_dialog.is_some() && parent_dialog != Some(DialogKind::Settings) {
return;
}
let export_selected = !self.selected_connection_ids.is_empty();
let candidates = self
.config
.sessions()
.iter()
.filter(|session| {
!export_selected || self.selected_connection_ids.contains(&session.id)
})
.cloned()
.collect::<Vec<_>>();
let skipped = candidates
.iter()
.filter(|session| !is_ssh_session(session))
.count();
let sessions = candidates
.into_iter()
.filter(is_ssh_session)
.collect::<Vec<_>>();
if sessions.is_empty() {
self.status = t!("connections_export_no_ssh").to_string().into();
cx.notify();
return;
}
let count = sessions.len();
let title = t!("connections_export_plaintext_title").to_string();
let description = if skipped == 0 {
t!("connections_export_plaintext_warning", count = count).to_string()
} else {
t!(
"connections_export_plaintext_warning_with_skipped",
count = count,
skipped = skipped
)
.to_string()
};
let confirm_text = t!("export_connections").to_string();
let cancel_text = t!("cancel").to_string();
let view = cx.entity();
let view_for_cancel = view.clone();
self.active_dialog = Some(DialogKind::ConnectionExport);
cx.notify();
window.open_alert_dialog(cx, move |alert, _, _| {
let view_for_ok = view.clone();
let sessions_for_export = sessions.clone();
alert
.title(title.clone())
.description(description.clone())
.button_props(
DialogButtonProps::default()
.ok_text(confirm_text.clone())
.cancel_text(cancel_text.clone())
.show_cancel(true),
)
.on_ok(move |_, window, cx| {
let sessions = sessions_for_export.clone();
view_for_ok.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = parent_dialog;
}
this.start_connection_csv_export(sessions, window, cx);
cx.notify();
});
true
})
.on_cancel({
let view = view_for_cancel.clone();
move |_, _, cx| {
view.update(cx, |this, cx| {
if this.active_dialog == Some(DialogKind::ConnectionExport) {
this.active_dialog = parent_dialog;
}
cx.notify();
});
true
}
})
});
}
fn start_connection_csv_export(
&mut self,
sessions: Vec<Session>,
window: &mut gpui::Window,
cx: &mut Context<Self>,
) {
let count = sessions.len();
cx.spawn_in(window, async move |this, cx| {
let encoded = cx
.background_executor()
.spawn(async move { encode_connection_csv(&sessions) })
.await;
let contents = match encoded {
Ok(contents) => contents,
Err(err) => {
tracing::error!("failed to prepare connection export: {err:#}");
let reason = err.to_string();
let _ = gpui::AsyncWindowContext::update(cx, |_, cx| {
let _ = this.update(cx, |this, cx| {
this.status = t!("connections_export_failed", reason = reason)
.to_string()
.into();
cx.notify();
});
});
return Ok::<(), anyhow::Error>(());
}
};
let file_dialog = rfd::AsyncFileDialog::new()
.set_file_name("ashell-connections.csv")
.add_filter("CSV", &["csv"])
.save_file();
if let Some(file_handle) = file_dialog.await {
let path = file_handle.path().to_path_buf();
let result = cx
.background_executor()
.spawn(async move { write_connection_csv(&path, &contents) })
.await;
let _ = gpui::AsyncWindowContext::update(cx, |_, cx| {
let _ = this.update(cx, |this, cx| {
this.status = match result {
Ok(()) => t!("connections_exported", count = count).to_string().into(),
Err(err) => {
tracing::error!("failed to export connections: {err:#}");
t!("connections_export_failed", reason = err.to_string())
.to_string()
.into()
}
};
cx.notify();
});
});
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
pub(crate) fn import_connections(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) {
let file_dialog = rfd::AsyncFileDialog::new()
.add_filter("CSV", &["csv"])
.pick_file();
cx.spawn_in(window, async move |this, cx| {
if let Some(file_handle) = file_dialog.await {
let path = file_handle.path().to_path_buf();
let result = cx
.background_executor()
.spawn(async move {
let contents = std::fs::read(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
decode_connection_csv(&contents)
})
.await;
let _ = gpui::AsyncWindowContext::update(cx, |_, cx| {
let _ = this.update(cx, |this, cx| {
match result {
Ok(sessions) => {
let previous_config = this.config.cache.clone();
let summary =
merge_csv_sessions(&mut this.config.cache.sessions, sessions);
this.config.sync_connection_groups_from_sessions();
match this.config.save() {
Ok(()) => {
this.selected_connection_ids.clear();
this.status = t!(
"connections_import_summary",
added = summary.added,
updated = summary.updated,
skipped = summary.skipped
)
.to_string()
.into();
}
Err(err) => {
this.config.cache = previous_config;
tracing::error!(
"failed to save imported connections: {err:#}"
);
this.status = t!(
"connections_import_failed",
reason = err.to_string()
)
.to_string()
.into();
}
}
}
Err(err) => {
tracing::error!("failed to import connections: {err:#}");
this.status =
t!("connections_import_failed", reason = err.to_string())
.to_string()
.into();
}
}
cx.notify();
});
});
}
Ok::<(), anyhow::Error>(())
})
.detach();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn password_session(id: &str, name: &str, host: &str, user: &str) -> Session {
let mut session =
Session::password(host.to_string(), 22, user.to_string(), "secret".to_string());
session.id = id.to_string();
session.name = name.to_string();
session
}
#[test]
fn csv_export_uses_minimal_columns_and_preserves_quoted_passwords() {
let mut password = password_session("password-id", "Primary, server", "one.test", "root");
password.password = "line one,\nline two".to_string();
password.group = "Production".to_string();
let mut key = Session::key(
"two.test".to_string(),
2202,
"deploy".to_string(),
"/keys/id_ed25519".to_string(),
"-----BEGIN PRIVATE KEY-----\nkey data\n-----END PRIVATE KEY-----\n".to_string(),
"key phrase".to_string(),
);
key.id = "key-id".to_string();
key.name = "Key server".to_string();
let bytes = encode_connection_csv(&[password, key]).expect("encode CSV");
assert!(bytes.starts_with(&[0xef, 0xbb, 0xbf]));
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("\r\n"));
assert_eq!(
text.trim_start_matches('\u{feff}').split("\r\n").next(),
Some("name,group,host,port,username,password")
);
assert!(!text.contains("auth_type"));
assert!(!text.contains("private_key_path"));
assert!(!text.contains("private_key_content"));
assert!(!text.contains("passphrase"));
assert!(!text.contains("password-id"));
assert!(!text.contains("BEGIN PRIVATE KEY"));
assert!(!text.contains("key phrase"));
let connections = decode_connection_csv(&bytes).expect("decode CSV");
assert_eq!(connections.len(), 2);
assert_eq!(connections[0].session.group, "Production");
assert_eq!(connections[0].session.password, "line one,\nline two");
assert_eq!(connections[0].session.auth, AuthMethod::Password);
assert_eq!(connections[1].session.auth, AuthMethod::Key);
assert!(connections[1].session.password.is_empty());
assert!(connections[1].session.private_key_path.is_empty());
assert!(connections[1].session.private_key_inline.is_empty());
assert!(connections[1].session.passphrase.is_empty());
}
#[test]
fn csv_import_accepts_common_aliases_and_defaults_the_port() {
let csv = concat!(
"Title,Hostname,SSH Port,User,Authentication,Pass,Ignored Column\r\n",
"Production,EXAMPLE.test,,root,password,secret,ignored\r\n"
);
let connections = decode_connection_csv(csv.as_bytes()).expect("decode aliased CSV");
assert_eq!(connections.len(), 1);
assert_eq!(connections[0].session.name, "Production");
assert_eq!(connections[0].session.host, "EXAMPLE.test");
assert_eq!(connections[0].session.port, 22);
assert_eq!(connections[0].session.user, "root");
assert_eq!(connections[0].session.password, "secret");
assert!(connections[0].columns.auth_type);
}
#[test]
fn csv_import_reports_the_invalid_row() {
let csv = "host,username,port\r\none.test,root,22\r\ntwo.test,root,70000\r\n";
let error = decode_connection_csv(csv.as_bytes()).expect_err("invalid port must fail");
assert!(
error.to_string().contains("CSV line 3"),
"unexpected error: {error:#}"
);
}
#[test]
fn csv_import_remains_compatible_with_optional_legacy_columns() {
let csv = concat!(
"name,host,username,auth_type,private_key_path,private_key_content,passphrase,id\r\n",
"Legacy,key.test,root,key,/keys/id_test,",
"\"-----BEGIN PRIVATE KEY-----\\nkey\\n-----END PRIVATE KEY-----\",",
"key phrase,external-id\r\n"
);
let connections = decode_connection_csv(csv.as_bytes()).expect("decode legacy CSV");
assert_eq!(connections.len(), 1);
let connection = &connections[0];
assert_eq!(connection.session.auth, AuthMethod::Key);
assert!(connection.session.private_key_path.is_empty());
assert!(
connection
.session
.private_key_inline
.contains("BEGIN PRIVATE KEY")
);
assert_eq!(connection.session.passphrase, "key phrase");
assert_ne!(connection.session.id, "external-id");
assert!(connection.columns.private_key_path);
assert!(connection.columns.private_key_content);
assert!(connection.columns.passphrase);
}
#[test]
fn csv_merge_uses_the_last_duplicate_row() {
let csv = concat!(
"name,host,port,username,password\r\n",
"First,same.test,22,root,old\r\n",
"Last,SAME.test,22,root,latest\r\n"
);
let imported = decode_connection_csv(csv.as_bytes()).expect("decode duplicate CSV");
let mut local = Vec::new();
let summary = merge_csv_sessions(&mut local, imported);
assert_eq!(
summary,
ConnectionImportSummary {
added: 1,
updated: 0,
skipped: 0,
}
);
assert_eq!(local.len(), 1);
assert_eq!(local[0].name, "Last");
assert_eq!(local[0].password, "latest");
}
#[test]
fn csv_merge_preserves_local_only_fields_and_skips_ambiguous_matches() {
let mut identity_match = Session::key(
"match.test".to_string(),
22,
"deploy".to_string(),
"/keys/local".to_string(),
String::new(),
"local phrase".to_string(),
);
identity_match.id = "local-id".to_string();
identity_match.name = "Identity".to_string();
identity_match.group = "Existing group".to_string();
identity_match.proxy_type = "socks5".to_string();
identity_match.proxy_host = "proxy.test".to_string();
identity_match.last_used = Some("2026-08-14T10:00:00Z".to_string());
let duplicate_one = password_session("duplicate-1", "Duplicate 1", "dupe.test", "root");
let duplicate_two = password_session("duplicate-2", "Duplicate 2", "DUPE.test", "root");
let mut local = vec![identity_match, duplicate_one, duplicate_two];
let csv = concat!(
"name,host,port,username,password\r\n",
"Matched,MATCH.test,22,deploy,\r\n",
"Added,added.test,22,root,new password\r\n",
"Ambiguous,dupe.test,22,root,password\r\n"
);
let imported = decode_connection_csv(csv.as_bytes()).expect("decode merge CSV");
let summary = merge_csv_sessions(&mut local, imported);
assert_eq!(
summary,
ConnectionImportSummary {
added: 1,
updated: 1,
skipped: 1,
}
);
let identity = local
.iter()
.find(|session| session.id == "local-id")
.expect("identity match");
assert_eq!(identity.name, "Matched");
assert_eq!(identity.group, "Existing group");
assert_eq!(identity.auth, AuthMethod::Key);
assert_eq!(identity.private_key_path, "/keys/local");
assert_eq!(identity.passphrase, "local phrase");
assert_eq!(identity.proxy_type, "socks5");
assert_eq!(identity.proxy_host, "proxy.test");
assert_eq!(identity.last_used.as_deref(), Some("2026-08-14T10:00:00Z"));
assert!(local.iter().any(|session| session.host == "added.test"));
}
#[test]
fn csv_merge_ignores_legacy_ids_and_preserves_an_unlisted_name() {
let local = password_session("local-id", "Custom name", "same.test", "root");
let csv = concat!(
"host,port,username,password,id\r\n",
"same.test,22,root,new password,other-id\r\n"
);
let imported = decode_connection_csv(csv.as_bytes()).expect("decode CSV with legacy ID");
let mut sessions = vec![local];
let summary = merge_csv_sessions(&mut sessions, imported);
assert_eq!(summary.updated, 1);
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "local-id");
assert_eq!(sessions[0].name, "Custom name");
assert_eq!(sessions[0].password, "new password");
}
#[test]
fn csv_import_updates_group_only_when_the_column_is_present() {
let mut local = password_session("local-id", "Local", "same.test", "root");
local.group = "Old group".to_string();
let mut sessions = vec![local];
let without_group = decode_connection_csv(
b"name,host,port,username,password\r\nUpdated,same.test,22,root,new\r\n",
)
.expect("decode CSV without group");
merge_csv_sessions(&mut sessions, without_group);
assert_eq!(sessions[0].group, "Old group");
let with_group = decode_connection_csv(
b"name,group,host,port,username,password\r\nUpdated,New group,same.test,22,root,new\r\n",
)
.expect("decode CSV with group");
merge_csv_sessions(&mut sessions, with_group);
assert_eq!(sessions[0].group, "New group");
}
}
+2 -9
View File
@@ -1,13 +1,6 @@
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;
#[allow(dead_code)]
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
#[allow(dead_code)]
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
pub(crate) const SIDEBAR_WIDTH: f32 = 310.0;
pub(crate) const TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0;
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
+232
View File
@@ -0,0 +1,232 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, ClipboardItem, ElementId, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Rems, RenderOnce, SharedString, StatefulInteractiveElement as _,
Styled as _, Window, div, prelude::FluentBuilder as _, px, rems,
};
use gpui_component::{
ActiveTheme as _, Icon, IconName, Sizable as _, Size,
button::{Button, ButtonVariants as _},
checkbox::Checkbox,
switch::Switch,
};
/// Shared application button size, matching the SSH file panel header controls.
pub(crate) const APP_BUTTON_SIZE: Size = Size::Small;
pub(crate) fn pointer_button(id: impl Into<ElementId>) -> Button {
Button::new(id).with_size(APP_BUTTON_SIZE).cursor_pointer()
}
/// Keep intentionally compact text readable when a view uses relative sizes.
pub(crate) fn ui_rems(size: f32) -> Rems {
rems(size.max(0.85))
}
pub(crate) fn pointer_checkbox(id: impl Into<ElementId>) -> Checkbox {
Checkbox::new(id).cursor_pointer()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SelectionState {
Unchecked,
Indeterminate,
Checked,
}
impl SelectionState {
pub(crate) fn from_counts(selected: usize, total: usize) -> Self {
if total == 0 || selected == 0 {
Self::Unchecked
} else if selected >= total {
Self::Checked
} else {
Self::Indeterminate
}
}
fn next_checked(self) -> bool {
self != Self::Checked
}
}
type SelectionClickHandler = Rc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
#[derive(IntoElement)]
pub(crate) struct PointerSelectionCheckbox {
id: ElementId,
state: SelectionState,
disabled: bool,
on_click: Option<SelectionClickHandler>,
}
impl PointerSelectionCheckbox {
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
state: SelectionState::Unchecked,
disabled: false,
on_click: None,
}
}
pub(crate) fn state(mut self, state: SelectionState) -> Self {
self.state = state;
self
}
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub(crate) fn on_click(
mut self,
handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PointerSelectionCheckbox {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let selected = self.state != SelectionState::Unchecked;
let border_color = if selected {
cx.theme().primary
} else {
cx.theme().input
};
let color = if self.disabled {
border_color.opacity(0.5)
} else {
border_color
};
let icon_color = if self.disabled {
cx.theme().primary_foreground.opacity(0.5)
} else {
cx.theme().primary_foreground
};
let radius = cx.theme().radius.min(px(4.));
let next_checked = self.state.next_checked();
let on_click = self.on_click.clone();
div()
.id(self.id)
.size(px(16.))
.flex_none()
.flex()
.items_center()
.justify_center()
.border_1()
.border_color(color)
.rounded(radius)
.bg(if selected {
color
} else {
cx.theme().input_background()
})
.when(cx.theme().shadow && !self.disabled, |this| this.shadow_xs())
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation())
.when(!self.disabled, |this| {
this.cursor_pointer().on_click(move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
if let Some(on_click) = &on_click {
on_click(&next_checked, window, cx);
}
})
})
.when_some(
match self.state {
SelectionState::Unchecked => None,
SelectionState::Indeterminate => Some(IconName::Minus),
SelectionState::Checked => Some(IconName::Check),
},
|this, icon| {
this.child(
Icon::new(icon)
.with_size(Size::Small)
.text_color(icon_color),
)
},
)
}
}
pub(crate) fn pointer_switch(id: impl Into<ElementId>) -> Switch {
Switch::new(id).cursor_pointer()
}
#[derive(IntoElement)]
pub(crate) struct PointerClipboard {
id: ElementId,
value: SharedString,
tooltip: Option<SharedString>,
}
impl PointerClipboard {
pub(crate) fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
value: SharedString::default(),
tooltip: None,
}
}
pub(crate) fn value(mut self, value: impl Into<SharedString>) -> Self {
self.value = value.into();
self
}
pub(crate) fn tooltip(mut self, tooltip: impl Into<SharedString>) -> Self {
self.tooltip = Some(tooltip.into());
self
}
}
impl RenderOnce for PointerClipboard {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| ClipboardState::default());
let copied = state.read(cx).copied;
pointer_button(self.id)
.icon(if copied {
IconName::Check
} else {
IconName::Copy
})
.ghost()
.small()
.when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
.when(!copied, |this| {
let state = state.clone();
let value = self.value.clone();
this.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.write_to_clipboard(ClipboardItem::new_string(value.to_string()));
state.update(cx, |state, cx| {
state.copied = true;
cx.notify();
});
let state = state.clone();
cx.spawn(async move |cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
state.update(cx, |state, cx| {
state.copied = false;
cx.notify();
});
})
.detach();
})
})
}
}
#[derive(Default)]
struct ClipboardState {
copied: bool,
}
+3071 -640
View File
File diff suppressed because it is too large Load Diff
+76 -11
View File
@@ -3,13 +3,13 @@ use gpui::{
};
use gpui_component::{
Sizable,
button::{Button, ButtonVariants},
button::ButtonVariants,
kbd::Kbd,
setting::{SettingField, SettingGroup, SettingItem},
};
use rust_i18n::t;
use crate::{Ashell, session::config::ConfigStore};
use crate::{Ashell, app::controls::pointer_button, session::config::ConfigStore};
gpui::actions!(
ashell_workspace,
@@ -19,6 +19,7 @@ gpui::actions!(
OpenTransfers,
NewSsh,
OpenSearch,
QuitApplication,
ToggleSidebar,
ToggleSftpZoom,
FocusPaneLeft,
@@ -62,7 +63,7 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
},
WorkspaceAction {
id: "NewSsh",
label_key: "settings_new_ssh",
label_key: "settings_new_connection",
default_suffix: "n",
},
WorkspaceAction {
@@ -70,6 +71,11 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
label_key: "settings_open_search",
default_suffix: "f",
},
WorkspaceAction {
id: "QuitApplication",
label_key: "settings_quit_application",
default_suffix: "q",
},
WorkspaceAction {
id: "ToggleSidebar",
label_key: "settings_toggle_sidebar",
@@ -128,12 +134,20 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
WorkspaceAction {
id: "Copy",
label_key: "settings_copy",
default_suffix: if cfg!(target_os = "macos") { "c" } else { "shift-c" },
default_suffix: if cfg!(target_os = "macos") {
"c"
} else {
"shift-c"
},
},
WorkspaceAction {
id: "Paste",
label_key: "settings_paste",
default_suffix: if cfg!(target_os = "macos") { "v" } else { "shift-v" },
default_suffix: if cfg!(target_os = "macos") {
"v"
} else {
"shift-v"
},
},
];
@@ -146,6 +160,10 @@ pub(crate) fn default_modifier() -> &'static str {
}
pub(crate) fn default_keystroke(action_id: &str) -> Option<String> {
if action_id == "QuitApplication" && cfg!(target_os = "windows") {
return Some("alt-f4".to_string());
}
WORKSPACE_ACTIONS
.iter()
.find(|action| action.id == action_id)
@@ -198,7 +216,41 @@ 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 suspend_quit_keybinding(cx: &mut App, config: &ConfigStore) {
let default = default_keystroke("QuitApplication").expect("quit action has a default key");
let configured =
configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone());
let action_name = crate::QuitApplication.name();
let mut bindings = vec![KeyBinding::new(&default, Unbind(action_name.into()), None)];
if configured != default && configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
None,
));
}
cx.bind_keys(bindings);
}
pub(crate) fn restore_quit_keybinding(cx: &mut App, config: &ConfigStore) {
let default = default_keystroke("QuitApplication").expect("quit action has a default key");
let configured =
configured_keystroke(config, "QuitApplication").unwrap_or_else(|| default.clone());
let action_name = crate::QuitApplication.name();
let mut bindings = Vec::new();
if configured != default {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
if configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(&configured, crate::QuitApplication, None));
}
cx.bind_keys(bindings);
}
/// Unbind workspace keybindings that could interfere with key recording.
///
/// The quit binding remains active so the application can always be closed while
/// the settings dialog is open.
pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
let mut bindings = Vec::new();
@@ -210,7 +262,7 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
// Unbind both the default and configured keystroke
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
if configured != default {
if configured != default && configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(
&configured,
Unbind(action_name.into()),
@@ -254,7 +306,7 @@ pub(crate) fn find_conflict(
continue;
}
let existing = configured_keystroke(config, action.id).unwrap_or_default();
if !existing.is_empty() && existing == new_keystroke {
if !existing.is_empty() && existing != "none" && existing == new_keystroke {
return Some((action.id.to_string(), t!(action.label_key).to_string()));
}
}
@@ -274,7 +326,9 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
}
bindings.push(KeyBinding::new(&configured, $action, None));
if configured != "none" && !configured.is_empty() {
bindings.push(KeyBinding::new(&configured, $action, None));
}
};
}
@@ -283,6 +337,7 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
bind_action!("OpenTransfers", crate::OpenTransfers);
bind_action!("NewSsh", crate::NewSsh);
bind_action!("OpenSearch", crate::OpenSearch);
bind_action!("QuitApplication", crate::QuitApplication);
bind_action!("ToggleSidebar", crate::ToggleSidebar);
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
@@ -305,7 +360,16 @@ impl KeybindingsPage {
let groups = [
(
"settings_group_keybind_general",
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch", "Copy", "Paste"],
vec![
"OpenSettings",
"OpenSession",
"OpenTransfers",
"NewSsh",
"OpenSearch",
"QuitApplication",
"Copy",
"Paste",
],
),
(
"settings_group_keybind_zoom",
@@ -377,7 +441,7 @@ impl KeybindingsPage {
let view = view.clone();
let action_id = action.id.to_string();
move |_, _window, _cx| {
Button::new(gpui::SharedString::from(format!("keybind-{action_id}")))
pointer_button(gpui::SharedString::from(format!("keybind-{action_id}")))
.label(btn_label.clone())
.small()
.when(recording, |this| this.primary())
@@ -389,6 +453,7 @@ impl KeybindingsPage {
view.update(cx, |this, cx| {
// Clear any previous error when starting new recording
this.keybind_error = None;
suspend_quit_keybinding(cx, &this.config);
this.recording_action = Some(action_id.clone());
this.focus_handle.focus(window, cx);
cx.notify();
+1279 -204
View File
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -131,6 +131,7 @@ impl ResizableState {
&mut self,
axis: Axis,
panels_count: usize,
fit_to_container: bool,
cx: &mut Context<Self>,
) {
let mut changed = self.axis != axis;
@@ -150,7 +151,7 @@ impl ResizableState {
changed = true;
}
if changed {
if changed && fit_to_container {
// We need to make sure the total size is in line with the container size.
self.adjust_to_container_size(cx);
}
@@ -299,6 +300,29 @@ impl ResizableState {
cx.notify();
}
pub(crate) fn resize_panel_independently(
&mut self,
ix: usize,
size: Pixels,
_: &mut Window,
cx: &mut Context<Self>,
) {
if ix >= self.sizes.len() {
return;
}
self.sync_real_panel_sizes(cx);
let size_range = self.panel_size_range(ix);
let new_size = size.clamp(size_range.start, size_range.end);
if new_size == self.sizes[ix] {
return;
}
self.sizes[ix] = new_size;
self.panels[ix].size = Some(new_size);
cx.notify();
}
pub(crate) fn adjust_to_container_size(&mut self, cx: &mut Context<Self>) {
if self.container_size().is_zero() {
return;
+69 -41
View File
@@ -4,17 +4,17 @@ use std::{
};
use gpui::{
Along, AnyElement, App, Axis, Bounds, Context, Element, ElementId, Empty, Entity,
EventEmitter, InteractiveElement as _, IntoElement, MouseMoveEvent, MouseUpEvent,
ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled, Window, div,
prelude::FluentBuilder, AppContext as _, IsZero as _,
Along, AnyElement, App, AppContext as _, Axis, Bounds, Context, Element, ElementId, Empty,
Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent,
MouseUpEvent, ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled,
Window, div, prelude::FluentBuilder,
};
use gpui_component::{
AxisExt, ElementExt, h_flex, v_flex, StyledExt,
};
use gpui_component::{AxisExt, ElementExt, StyledExt, h_flex, v_flex};
use super::{ResizableState, resizable_panel, resize_handle, PANEL_MIN_SIZE, ResizablePanelEvent};
use super::{PANEL_MIN_SIZE, ResizablePanelEvent, ResizableState, resizable_panel, resize_handle};
type ResizeCallback = Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>;
#[derive(Clone)]
pub(crate) struct DragPanel;
@@ -32,8 +32,9 @@ pub struct ResizablePanelGroup {
axis: Axis,
size: Option<Pixels>,
children: Vec<ResizablePanel>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
on_resize: ResizeCallback,
locked: bool,
independent_resize: bool,
}
impl ResizablePanelGroup {
@@ -47,6 +48,7 @@ impl ResizablePanelGroup {
size: None,
on_resize: Rc::new(|_, _, _| {}),
locked: false,
independent_resize: false,
}
}
@@ -70,6 +72,13 @@ impl ResizablePanelGroup {
self
}
/// Resize each panel without redistributing space among its siblings.
/// This lets the total group size grow and adds a trailing handle to the last panel.
pub fn independent_resize(mut self) -> Self {
self.independent_resize = true;
self
}
/// Add a panel to the group.
///
/// - The `axis` will be set to the same axis as the group.
@@ -143,7 +152,7 @@ impl RenderOnce for ResizablePanelGroup {
// Sync panels to the state
let panels_count = self.children.len();
state.update(cx, |state, cx| {
state.sync_panels_count(self.axis, panels_count, cx);
state.sync_panels_count(self.axis, panels_count, !self.independent_resize, cx);
});
container
@@ -158,6 +167,8 @@ impl RenderOnce for ResizablePanelGroup {
panel.axis = self.axis;
panel.state = Some(state.clone());
panel.locked = self.locked;
panel.trailing_resize_handle =
self.independent_resize && ix + 1 == panels_count;
panel
}),
)
@@ -170,7 +181,7 @@ impl RenderOnce for ResizablePanelGroup {
state.bounds = bounds;
if size_changed {
if size_changed && !self.independent_resize {
state.adjust_to_container_size(cx);
}
})
@@ -180,6 +191,7 @@ impl RenderOnce for ResizablePanelGroup {
state: state.clone(),
axis: self.axis,
on_resize: self.on_resize.clone(),
independent_resize: self.independent_resize,
})
}
}
@@ -198,6 +210,7 @@ pub struct ResizablePanel {
visible: bool,
style: StyleRefinement,
locked: bool,
trailing_resize_handle: bool,
}
impl ResizablePanel {
@@ -213,6 +226,7 @@ impl ResizablePanel {
visible: true,
style: StyleRefinement::default(),
locked: false,
trailing_resize_handle: false,
}
}
@@ -264,6 +278,8 @@ impl RenderOnce for ResizablePanel {
.get(self.panel_ix)
.expect("BUG: The `index` of ResizablePanel should be one of in `state`.");
let size_range = self.size_range.clone();
let leading_handle_state = state.clone();
let trailing_handle_state = state.clone();
div()
.id(("resizable-panel", self.panel_ix))
@@ -305,16 +321,32 @@ impl RenderOnce for ResizablePanel {
let handle = if self.locked {
handle
} else {
handle.on_drag(
DragPanel,
move |drag_panel, _, _, cx| {
cx.stop_propagation();
state.update(cx, |state, _| {
state.resizing_panel_ix = Some(ix);
});
cx.new(|_| drag_panel.deref().clone())
},
)
handle.on_drag(DragPanel, move |drag_panel, _, _, cx| {
cx.stop_propagation();
leading_handle_state.update(cx, |state, cx| {
state.resizing_panel_ix = Some(ix);
cx.notify();
});
cx.new(|_| drag_panel.deref().clone())
})
};
this.child(handle)
})
.when(self.trailing_resize_handle, |this| {
let ix = self.panel_ix;
let handle = resize_handle(("resizable-handle", ix), self.axis)
.placement(gpui_component::dock::DockPlacement::Left);
let handle = if self.locked {
handle
} else {
handle.on_drag(DragPanel, move |drag_panel, _, _, cx| {
cx.stop_propagation();
trailing_handle_state.update(cx, |state, cx| {
state.resizing_panel_ix = Some(ix);
cx.notify();
});
cx.new(|_| drag_panel.deref().clone())
})
};
this.child(handle)
})
@@ -323,8 +355,9 @@ impl RenderOnce for ResizablePanel {
struct ResizePanelGroupElement {
state: Entity<ResizableState>,
on_resize: Rc<dyn Fn(&Entity<ResizableState>, &mut Window, &mut App)>,
on_resize: ResizeCallback,
axis: Axis,
independent_resize: bool,
}
impl IntoElement for ResizePanelGroupElement {
@@ -366,7 +399,6 @@ impl Element for ResizePanelGroupElement {
_window: &mut Window,
_cx: &mut App,
) -> Self::PrepaintState {
()
}
fn paint(
@@ -377,34 +409,31 @@ impl Element for ResizePanelGroupElement {
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
_cx: &mut App,
) {
window.on_mouse_event({
let state = self.state.clone();
let axis = self.axis;
let current_ix = state.read(cx).resizing_panel_ix;
let independent_resize = self.independent_resize;
move |e: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let Some(ix) = current_ix else { return };
let Some(ix) = state.read(cx).resizing_panel_ix else {
return;
};
state.update(cx, |state, cx| {
let panel = state.panels.get(ix).expect("BUG: invalid panel index");
match axis {
Axis::Horizontal => state.resize_panel_at_handle(
ix,
e.position.x - panel.bounds.left(),
window,
cx,
),
Axis::Vertical => state.resize_panel_at_handle(
ix,
e.position.y - panel.bounds.top(),
window,
cx,
),
let size = match axis {
Axis::Horizontal => e.position.x - panel.bounds.left(),
Axis::Vertical => e.position.y - panel.bounds.top(),
};
if independent_resize {
state.resize_panel_independently(ix, size, window, cx);
} else {
state.resize_panel_at_handle(ix, size, window, cx);
}
cx.notify();
})
@@ -414,10 +443,9 @@ impl Element for ResizePanelGroupElement {
// When any mouse up, stop dragging
window.on_mouse_event({
let state = self.state.clone();
let current_ix = state.read(cx).resizing_panel_ix;
let on_resize = self.on_resize.clone();
move |_: &MouseUpEvent, phase, window, cx| {
if current_ix.is_none() {
if state.read(cx).resizing_panel_ix.is_none() {
return;
}
if phase.bubble() {
+13 -12
View File
@@ -1,9 +1,9 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
div, prelude::FluentBuilder as _, px, AnyElement, App, Axis, Element, ElementId, Entity,
GlobalElementId, InteractiveElement, IntoElement, MouseDownEvent, MouseUpEvent,
ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window,
AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, InteractiveElement,
IntoElement, MouseDownEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render,
StatefulInteractiveElement, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement};
@@ -11,6 +11,8 @@ use gpui_component::{ActiveTheme as _, AxisExt as _, dock::DockPlacement};
pub(crate) const HANDLE_PADDING: Pixels = px(4.);
pub(crate) const HANDLE_SIZE: Pixels = px(1.);
type DragCallback<E> = Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>;
/// Create a resize handle for a resizable panel.
pub(crate) fn resize_handle<T: 'static, E: 'static + Render>(
id: impl Into<ElementId>,
@@ -24,7 +26,7 @@ pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
axis: Axis,
drag_value: Option<Rc<T>>,
placement: Option<DockPlacement>,
on_drag: Option<Rc<dyn Fn(&Point<Pixels>, &mut Window, &mut App) -> Entity<E>>>,
on_drag: Option<DragCallback<E>>,
}
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
@@ -126,14 +128,13 @@ impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
)
})
.map(|this| match self.placement {
Some(DockPlacement::Left) => {
this.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING)
}
Some(DockPlacement::Left) => this
.when(has_drag, |this| this.cursor_col_resize())
.top_0()
.right(px(1.))
.h_full()
.w(HANDLE_SIZE)
.pl(HANDLE_PADDING),
_ => this
.when(axis.is_horizontal(), |this| {
this.when(has_drag, |this| this.cursor_col_resize())
+99 -132
View File
@@ -1,17 +1,19 @@
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,
Context, Focusable as _, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px,
};
use gpui_component::{
ActiveTheme as _, Disableable as _, ElementExt as _, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex, input::Input,
button::ButtonVariants as _, h_flex, input::Input,
};
use rust_i18n::t;
use crate::Ashell;
use crate::{
Ashell,
app::controls::{pointer_button, ui_rems},
};
impl Ashell {
pub(crate) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -59,6 +61,20 @@ impl Ashell {
});
}
fn search_tab_index(&self) -> Option<usize> {
self.active_tab
.as_deref()
.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
.or_else(|| {
self.active_group
.as_ref()
.and_then(|group_id| self.tab_groups.iter().find(|group| &group.id == group_id))
.and_then(|group| group.pane_root.first_tab_id())
.and_then(|id| self.tabs.iter().position(|tab| tab.id == id))
})
.or_else(|| (!self.tabs.is_empty()).then_some(0))
}
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() {
@@ -70,28 +86,7 @@ impl Ashell {
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 {
let Some(tab_index) = self.search_tab_index() else {
self.status = t!("no_results").into();
self.refocus_search_input(window, cx);
cx.notify();
@@ -99,7 +94,8 @@ impl Ashell {
};
// Remember which tab was searched so highlights only appear in that pane.
self.search_target_tab = Some(tab.id.clone());
self.search_target_tab = Some(self.tabs[tab_index].id.clone());
let tab = &self.tabs[tab_index];
let query_lower = query.to_lowercase();
let query_byte_len = query.len();
@@ -143,6 +139,7 @@ impl Ashell {
}
}
matches.sort_unstable();
let match_count = count_match_groups(&matches);
self.search_query = query;
@@ -203,7 +200,7 @@ impl Ashell {
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())
.and_then(|g| g.pane_root.first_tab_id())
.map(|s| s.to_string())
});
@@ -214,7 +211,7 @@ impl Ashell {
};
if let Some(tab) = tab {
let snapshot = tab.render_snapshot();
let snapshot = tab.render_snapshot(false);
let display_offset = snapshot.display_offset as i32;
let rows = snapshot.rows as i32;
@@ -251,37 +248,14 @@ impl Ashell {
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 tab = self.tabs.get(self.search_tab_index()?)?;
let snapshot = tab.render_snapshot(false);
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 sorted = &self.search_matches;
let mut group_idx = 0;
let mut i = 0;
@@ -296,50 +270,32 @@ impl Ashell {
// 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;
let next_i = next_match_group_index(sorted, 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;
for &(_, col) in &sorted[i..next_i] {
map.insert((vp_row, col), color);
}
}
group_idx += 1;
i = j;
i = next_i;
}
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 {
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);
})),
)
div().child(
pointer_button("search-btn")
.ghost()
.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).
@@ -365,14 +321,17 @@ impl Ashell {
.top(px(8.))
.right(px(24.))
.on_prepaint(move |bounds, _window, cx| {
let _ = view.update(cx, |this, _| {
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();
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
this.refocus_search_input(window, cx);
cx.stop_propagation();
}),
)
.child(
h_flex()
.gap_1()
@@ -385,19 +344,21 @@ impl Ashell {
.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()),
.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)),
)
.when(!current_display.is_empty(), |this| {
this.child(
div()
.text_size(rems(0.75))
.text_size(ui_rems(0.75))
.text_color(cx.theme().muted_foreground)
.min_w(px(36.))
.text_center()
@@ -405,9 +366,9 @@ impl Ashell {
)
})
.child(
Button::new("search-prev")
pointer_button("search-prev")
.ghost()
.xsmall()
.small()
.icon(IconName::ChevronUp)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
@@ -415,9 +376,9 @@ impl Ashell {
})),
)
.child(
Button::new("search-next")
pointer_button("search-next")
.ghost()
.xsmall()
.small()
.icon(IconName::ChevronDown)
.disabled(!has_matches)
.on_click(cx.listener(|this, _, _, cx| {
@@ -425,9 +386,9 @@ impl Ashell {
})),
)
.child(
Button::new("search-close")
pointer_button("search-close")
.ghost()
.xsmall()
.small()
.icon(IconName::Close)
.on_click(cx.listener(|this, _, window, cx| {
this.close_search(window, cx);
@@ -443,44 +404,50 @@ impl Ashell {
/// 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() {
while i < matches.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;
}
i = next_match_group_index(matches, i);
}
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() {
while i < matches.len() {
if group_idx == n {
return Some(sorted[i]);
return Some(matches[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;
}
i = next_match_group_index(matches, i);
}
None
}
fn next_match_group_index(matches: &[(i32, i32)], start: usize) -> usize {
let row = matches[start].0;
let mut end = start + 1;
while end < matches.len() && matches[end].0 == row && matches[end].1 == matches[end - 1].1 + 1 {
end += 1;
}
end
}
#[cfg(test)]
mod tests {
use super::{count_match_groups, find_nth_match_start};
#[test]
fn groups_consecutive_search_matches_by_row() {
let matches = [(0, 1), (0, 2), (0, 5), (1, 0), (1, 1)];
assert_eq!(count_match_groups(&matches), 3);
assert_eq!(find_nth_match_start(&matches, 0), Some((0, 1)));
assert_eq!(find_nth_match_start(&matches, 1), Some((0, 5)));
assert_eq!(find_nth_match_start(&matches, 2), Some((1, 0)));
assert_eq!(find_nth_match_start(&matches, 3), None);
}
}
+85 -35
View File
@@ -2,7 +2,75 @@ use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
use gpui_component::Root;
use crate::Ashell;
use crate::session::config::ConfigStore;
use crate::session::config::{ConfigStore, SavedWindowBounds};
fn clamp_saved_window_bounds(
x: f32,
y: f32,
width: f32,
height: f32,
cx: &App,
) -> Bounds<gpui::Pixels> {
let displays = cx.displays();
let Some(display) = displays.iter().max_by(|left, right| {
let intersection_area = |display: &std::rc::Rc<dyn gpui::PlatformDisplay>| {
let visible = display.visible_bounds();
let left_edge = x.max(visible.origin.x.as_f32());
let top_edge = y.max(visible.origin.y.as_f32());
let right_edge =
(x + width).min(visible.origin.x.as_f32() + visible.size.width.as_f32());
let bottom_edge =
(y + height).min(visible.origin.y.as_f32() + visible.size.height.as_f32());
(right_edge - left_edge).max(0.0) * (bottom_edge - top_edge).max(0.0)
};
intersection_area(left).total_cmp(&intersection_area(right))
}) else {
return Bounds::new(point(px(x), px(y)), size(px(width), px(height)));
};
let visible = display.visible_bounds();
let visible_x = visible.origin.x.as_f32();
let visible_y = visible.origin.y.as_f32();
let visible_width = visible.size.width.as_f32();
let visible_height = visible.size.height.as_f32();
let width = width.max(1.0).min(visible_width);
let height = height.max(1.0).min(visible_height);
let x = if x.is_finite() {
x.clamp(visible_x, visible_x + visible_width - width)
} else {
visible_x + (visible_width - width) / 2.0
};
let y = if y.is_finite() {
y.clamp(visible_y, visible_y + visible_height - height)
} else {
visible_y + (visible_height - height) / 2.0
};
Bounds::new(point(px(x), px(y)), size(px(width), px(height)))
}
fn restore_window_bounds(bounds: &SavedWindowBounds, cx: &App) -> gpui::WindowBounds {
match bounds {
SavedWindowBounds::Fullscreen {
x,
y,
width,
height,
} => gpui::WindowBounds::Fullscreen(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
SavedWindowBounds::Maximized {
x,
y,
width,
height,
} => gpui::WindowBounds::Maximized(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
SavedWindowBounds::Windowed {
x,
y,
width,
height,
} => gpui::WindowBounds::Windowed(clamp_saved_window_bounds(*x, *y, *width, *height, cx)),
}
}
pub(crate) fn bind_workspace_keys(cx: &mut gpui::App) {
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
@@ -179,7 +247,14 @@ pub(crate) fn sync_macos_launch_environment() {
}
fn read_proxy_from_env() -> Option<(String, String, Option<u16>, String, String)> {
let vars = ["ALL_PROXY", "all_proxy", "HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
let vars = [
"ALL_PROXY",
"all_proxy",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
];
for var in vars {
if let Ok(val) = std::env::var(var) {
if val.is_empty() {
@@ -244,35 +319,7 @@ pub(crate) fn open_main_window(cx: &mut App) {
}
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)),
)),
});
window_options.window_bounds = Some(restore_window_bounds(bounds, cx));
} else if let Some(display) = cx.displays().first().cloned() {
let display_bounds = display.bounds();
let width = display_bounds.size.width * 0.8;
@@ -296,21 +343,24 @@ pub(crate) fn open_main_window(cx: &mut App) {
window.set_window_title("ashell");
gpui_component::Theme::sync_system_appearance(Some(window), cx);
let view = cx.new(|cx| Ashell::new(window, cx));
crate::app::system_menu::set_app_menus(cx);
tracing::info!("[ui] main application window opened");
let focus_handle = view.read(cx).focus_handle.clone();
window.focus(&focus_handle, cx);
let view_clone = view.clone();
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
let handle = window.window_handle();
if !cx.windows().contains(&handle) {
tracing::warn!("[ui] window not found in app during close, skipping save layout state.");
tracing::warn!(
"[ui] window not found in app during close, skipping save layout state."
);
return true;
}
view_clone.read(cx).save_layout_state(window, cx);
view_clone.update(cx, |this, cx| {
this.save_layout_state(window, cx);
});
true
});
+132
View File
@@ -0,0 +1,132 @@
#[cfg(target_os = "macos")]
use gpui::KeyBinding;
use gpui::{App, Menu, MenuItem};
use rust_i18n::t;
gpui::actions!(
ashell_system_menu,
[
AboutAshell,
CloseWindow,
HideAshell,
HideOtherApplications,
MinimizeWindow,
NewLocalTerminal,
OpenProjectWebsite,
ShowAllApplications,
ToggleFullScreen,
ZoomWindow
]
);
const PROJECT_URL: &str = "https://github.com/rust-kotlin/ashell";
pub(crate) fn init(cx: &mut App) {
#[cfg(target_os = "macos")]
{
cx.on_action(|_: &HideAshell, cx| cx.hide());
cx.on_action(|_: &HideOtherApplications, cx| cx.hide_other_apps());
cx.on_action(|_: &ShowAllApplications, cx| cx.unhide_other_apps());
cx.bind_keys([
KeyBinding::new("cmd-h", HideAshell, None),
KeyBinding::new("alt-cmd-h", HideOtherApplications, None),
KeyBinding::new("cmd-m", MinimizeWindow, None),
KeyBinding::new("ctrl-cmd-f", ToggleFullScreen, None),
]);
}
cx.on_action(|_: &OpenProjectWebsite, _| {
if let Err(error) = open::that(PROJECT_URL) {
tracing::warn!("failed to open Ashell project website: {error}");
}
});
}
pub(crate) fn set_app_menus(cx: &mut App) {
let mut application_items = vec![
MenuItem::action(t!("menu_about_ashell").to_string(), AboutAshell),
MenuItem::separator(),
MenuItem::action(t!("settings").to_string(), crate::OpenSettings),
];
#[cfg(target_os = "macos")]
application_items.extend([
MenuItem::separator(),
MenuItem::os_submenu(
t!("menu_services").to_string(),
gpui::SystemMenuType::Services,
),
MenuItem::separator(),
MenuItem::action(t!("menu_hide_ashell").to_string(), HideAshell),
MenuItem::action(t!("menu_hide_others").to_string(), HideOtherApplications),
MenuItem::action(t!("menu_show_all").to_string(), ShowAllApplications),
]);
application_items.extend([
MenuItem::separator(),
MenuItem::action(t!("menu_quit_ashell").to_string(), crate::QuitApplication),
]);
cx.set_menus([
Menu::new("Ashell").items(application_items),
Menu::new(t!("menu_file").to_string()).items([
MenuItem::action(t!("local_terminal").to_string(), NewLocalTerminal),
MenuItem::action(t!("new_connection").to_string(), crate::NewSsh),
MenuItem::separator(),
MenuItem::action(t!("menu_close_window").to_string(), CloseWindow),
]),
Menu::new(t!("menu_edit").to_string()).items([
MenuItem::os_action(
t!("menu_undo").to_string(),
gpui_component::input::Undo,
gpui::OsAction::Undo,
),
MenuItem::os_action(
t!("menu_redo").to_string(),
gpui_component::input::Redo,
gpui::OsAction::Redo,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_cut").to_string(),
gpui_component::input::Cut,
gpui::OsAction::Cut,
),
MenuItem::os_action(
t!("menu_copy").to_string(),
crate::Copy,
gpui::OsAction::Copy,
),
MenuItem::os_action(
t!("menu_paste").to_string(),
crate::Paste,
gpui::OsAction::Paste,
),
MenuItem::separator(),
MenuItem::os_action(
t!("menu_select_all").to_string(),
gpui_component::input::SelectAll,
gpui::OsAction::SelectAll,
),
]),
Menu::new(t!("menu_view").to_string()).items([
MenuItem::action(t!("search").to_string(), crate::OpenSearch),
MenuItem::action(t!("menu_toggle_sidebar").to_string(), crate::ToggleSidebar),
MenuItem::action(
t!("settings_open_transfers").to_string(),
crate::OpenTransfers,
),
MenuItem::separator(),
MenuItem::action(t!("menu_toggle_full_screen").to_string(), ToggleFullScreen),
]),
// GPUI recognizes this exact title as the native macOS Window menu.
Menu::new("Window").items([
MenuItem::action(t!("menu_minimize").to_string(), MinimizeWindow),
MenuItem::action(t!("menu_zoom").to_string(), ZoomWindow),
]),
Menu::new(t!("menu_help").to_string()).items([MenuItem::action(
t!("menu_project_website").to_string(),
OpenProjectWebsite,
)]),
]);
}
+70 -9
View File
@@ -1,6 +1,7 @@
use anyhow::{Context as _, Result};
use gpui::{App, Context, SharedString, Window, px};
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
use rust_i18n::t;
use crate::Ashell;
@@ -16,8 +17,54 @@ use std::sync::atomic::{AtomicBool, Ordering};
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
pub(crate) const COMMON_SYSTEM_UI_FONTS: &[&str] = &[
"Helvetica",
"Segoe UI",
"Ubuntu",
"Adwaita Sans",
"Cantarell",
"Noto Sans",
"DejaVu Sans",
"Arial",
"San Francisco",
".SF NS Text",
".SF NS Display",
"Segoe UI Variable",
"PingFang SC",
"Microsoft YaHei",
"SimSun",
];
pub(crate) fn resolve_safe_ui_font(cx: &App, requested_font: &str) -> String {
let all_fonts = cx.text_system().all_font_names();
if requested_font == ".SystemUIFont" {
let has_system_ui = COMMON_SYSTEM_UI_FONTS
.iter()
.any(|font| all_fonts.contains(&font.to_string()));
if has_system_ui {
".SystemUIFont".to_string()
} else {
"Maple Mono NF CN".to_string()
}
} else if all_fonts.contains(&requested_font.to_string()) {
requested_font.to_string()
} else {
let has_system_ui = COMMON_SYSTEM_UI_FONTS
.iter()
.any(|font| all_fonts.contains(&font.to_string()));
if has_system_ui {
".SystemUIFont".to_string()
} else {
"Maple Mono NF CN".to_string()
}
}
}
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
let has_system_maple = cx.text_system().all_font_names().contains(&"Maple Mono NF CN".to_string());
let has_system_maple = cx
.text_system()
.all_font_names()
.contains(&"Maple Mono NF CN".to_string());
if has_system_maple {
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
} else {
@@ -31,7 +78,8 @@ pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
.add_fonts(vec![regular, bold])
.context("load Maple Mono NF CN fonts")?;
}
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
let safe_font = resolve_safe_ui_font(cx, ".SystemUIFont");
set_theme_font_names(cx.global_mut::<Theme>(), &safe_font);
Ok(())
}
@@ -123,9 +171,23 @@ impl Ashell {
}
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:#}");
}
crate::app::system_menu::set_app_menus(cx);
self.connection_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_connections").to_string(), window, cx);
});
self.connection_group_name_input.update(cx, |input, cx| {
input.set_placeholder(t!("connection_group_name").to_string(), window, cx);
});
self.command_history_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_command_history").to_string(), window, cx);
});
self.remote_process_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_processes").to_string(), window, cx);
});
self.remote_port_filter_input.update(cx, |input, cx| {
input.set_placeholder(t!("filter_ports").to_string(), window, cx);
});
self.save_preferences_background();
window.refresh();
cx.notify();
}
@@ -141,11 +203,12 @@ impl Ashell {
.get(&self.dark_theme_name)
.cloned()
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
let safe_font = resolve_safe_ui_font(cx, &self.ui_font_family);
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);
set_theme_font_names(theme, &safe_font);
if self.follow_system_theme {
Theme::sync_system_appearance(Some(window), cx);
@@ -165,8 +228,6 @@ impl Ashell {
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:#}");
}
self.save_preferences_background();
}
}
+4396 -1432
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
//! Window dragging helpers for the integrated title bar.
//!
//! GPUI implements `Window::start_window_move` on macOS and Linux, but the
//! Windows backend leaves it as a no-op. On Windows we post a native
//! `WM_SYSCOMMAND` / `SC_MOVE | HTCAPTION` message so the GPUI pointer callback
//! can finish before `DefWindowProc` enters its modal window-move loop.
/// Begin a native window drag from a mouse-down on the integrated title bar.
pub(crate) fn start_window_drag(window: &gpui::Window) {
#[cfg(target_os = "windows")]
{
use windows::Win32::Foundation::{HWND, LPARAM, WPARAM};
use windows::Win32::UI::Input::KeyboardAndMouse::ReleaseCapture;
use windows::Win32::UI::WindowsAndMessaging::{
HTCAPTION, PostMessageW, SC_MOVE, WM_SYSCOMMAND,
};
let Some(handle) = crate::desktop_notification::native_window_handle(window) else {
tracing::warn!("failed to start window drag: missing native window handle");
return;
};
unsafe {
// The button-down capture must be released before the system move
// loop can take over the pointer.
let _ = ReleaseCapture();
if let Err(err) = PostMessageW(
HWND(handle as _),
WM_SYSCOMMAND,
WPARAM((SC_MOVE | HTCAPTION) as usize),
LPARAM(0),
) {
tracing::warn!("failed to start native window drag: {err}");
}
}
}
#[cfg(not(target_os = "windows"))]
{
window.start_window_move();
}
}
+617 -15
View File
@@ -1,19 +1,480 @@
use std::{
fs,
io::{Read, Write},
sync::mpsc::{self, Sender},
path::{Path, PathBuf},
sync::{OnceLock, mpsc},
thread,
time::Duration,
};
#[cfg(not(windows))]
use std::time::Instant;
use anyhow::{Context, Result};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
#[cfg(not(windows))]
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
use crate::session::config::LocalTerminalShell;
use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender};
pub fn spawn_local_terminal(
#[cfg(not(windows))]
const DIRECTORY_POLL_INTERVAL: Duration = Duration::from_secs(1);
const BASH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
if [[ ${ASHELL_BASH_LOGIN:-0} == 1 ]]; then
[[ -r /etc/profile ]] && source /etc/profile
if [[ -r $HOME/.bash_profile ]]; then
source "$HOME/.bash_profile"
elif [[ -r $HOME/.bash_login ]]; then
source "$HOME/.bash_login"
elif [[ -r $HOME/.profile ]]; then
source "$HOME/.profile"
fi
elif [[ -r $HOME/.bashrc ]]; then
source "$HOME/.bashrc"
fi
if [[ -z ${__ASHELL_BASH_INTEGRATION_ACTIVE:-} ]]; then
__ASHELL_BASH_INTEGRATION_ACTIVE=1
__ashell_prompt_suffix='\[\e]133;B\a\]'
__ashell_secondary_prefix='\[\e]133;A;k=s\a\]'
__ashell_install_prompt_markers() {
PS1=${PS1//"$__ashell_prompt_suffix"/}
PS1+=$__ashell_prompt_suffix
PS2=${PS2//"$__ashell_secondary_prefix"/}
PS2=${PS2//"$__ashell_prompt_suffix"/}
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
}
__ashell_precmd() {
local __ashell_last_status=$?
local __ashell_cwd=$PWD
__ashell_install_prompt_markers
if [[ ${ASHELL_WINDOWS_GIT_BASH:-0} == 1 ]]; then
__ashell_cwd=$(pwd -W 2>/dev/null) || __ashell_cwd=$PWD
fi
printf '\033]133;D;%s\007\033]0;ASHELL_CWD:%s\007\033]133;A\007' \
"$__ashell_last_status" "$__ashell_cwd"
return "$__ashell_last_status"
}
case $(declare -p PROMPT_COMMAND 2>/dev/null) in
'declare -a'*)
PROMPT_COMMAND+=(__ashell_precmd)
;;
*)
[[ -n ${PROMPT_COMMAND:-} ]] && PROMPT_COMMAND+=$'\n'
PROMPT_COMMAND+='__ashell_precmd'
;;
esac
__ashell_install_prompt_markers
fi
"#;
const ZSH_ENV_WRAPPER: &str = r#"# Generated by ashell. Redirect startup back to the user's files first.
typeset -g ASHELL_USER_ZDOTDIR="${ASHELL_ORIGINAL_ZDOTDIR:-$HOME}"
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshenv" ]]; then
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
source "$ASHELL_USER_ZDOTDIR/.zshenv"
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
fi
export ASHELL_USER_ZDOTDIR
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
"#;
const ZSH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
if [[ ${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/* ]]; then
HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}"
fi
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshrc" ]]; then
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
source "$ASHELL_USER_ZDOTDIR/.zshrc"
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
fi
if [[ -z ${__ASHELL_ZSH_INTEGRATION_ACTIVE:-} ]]; then
typeset -g __ASHELL_ZSH_INTEGRATION_ACTIVE=1
typeset -g __ashell_prompt_suffix=$'%{\e]133;B\a%}'
typeset -g __ashell_secondary_prefix=$'%{\e]133;A;k=s\a%}'
__ashell_install_prompt_markers() {
PROMPT=${PROMPT//$__ashell_prompt_suffix/}
PROMPT+=$__ashell_prompt_suffix
PS2=${PS2//$__ashell_secondary_prefix/}
PS2=${PS2//$__ashell_prompt_suffix/}
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
}
__ashell_precmd() {
local __ashell_last_status=$?
__ashell_install_prompt_markers
print -rn -- $'\e]133;D;'"$__ashell_last_status"$'\a'
print -rn -- $'\e]0;ASHELL_CWD:'"$PWD"$'\a'
print -rn -- $'\e]133;A\a'
return "$__ashell_last_status"
}
__ashell_preexec() {
print -rn -- $'\e]133;C\a'
}
autoload -Uz add-zsh-hook
add-zsh-hook -d precmd __ashell_precmd 2>/dev/null
add-zsh-hook -d preexec __ashell_preexec 2>/dev/null
add-zsh-hook precmd __ashell_precmd
add-zsh-hook preexec __ashell_preexec
__ashell_install_prompt_markers
fi
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
"#;
const FISH_INTEGRATION_COMMAND: &str = r#"
if status --is-interactive; and not functions -q __ashell_original_fish_prompt
functions -c fish_prompt __ashell_original_fish_prompt
function __ashell_common_prompt
set -l __ashell_last_status $status
printf '\033]133;D;%s\007' $__ashell_last_status
printf '\033]0;ASHELL_CWD:%s\007' $PWD
printf '\033]133;A\007'
return $__ashell_last_status
end
function __ashell_preexec --on-event fish_preexec
printf '\033]133;C\007'
end
function fish_prompt
__ashell_common_prompt
__ashell_original_fish_prompt
printf '\033]133;B\007'
end
end
"#;
#[cfg(any(windows, test))]
const POWERSHELL_SHELL_INTEGRATION: &str = r#"& {
$global:AshellOriginalPrompt = $function:prompt
function global:prompt {
$promptText = if ($global:AshellOriginalPrompt) { & $global:AshellOriginalPrompt } else { "PS $PWD> " }
$cwd = $PWD.ProviderPath
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cwd))
[Console]::Write("$([char]27)]133;D$([char]7)$([char]27)]133;A$([char]7)")
[Console]::Write("$([char]27)]0;ASHELL_CWD_B64:$encoded$([char]7)")
"$promptText$([char]27)]133;B$([char]7)"
}
try {
$options = Get-PSReadLineOption -ErrorAction Stop
$continuation = "$([char]27)]133;A;k=s$([char]7)$($options.ContinuationPrompt)$([char]27)]133;B$([char]7)"
Set-PSReadLineOption -ContinuationPrompt $continuation -ErrorAction Stop
} catch {}
}"#;
#[derive(Debug)]
struct ShellIntegrationPaths {
bash_rc: PathBuf,
zsh_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileShellIntegration {
Bash { login: bool },
Zsh,
Fish,
}
static SHELL_INTEGRATION_PATHS: OnceLock<Result<ShellIntegrationPaths, String>> = OnceLock::new();
fn file_shell_integration(executable: &Path, login_bash: bool) -> Option<FileShellIntegration> {
let executable_name = executable
.file_stem()?
.to_string_lossy()
.to_ascii_lowercase();
match executable_name.as_str() {
"bash" => Some(FileShellIntegration::Bash { login: login_bash }),
"zsh" => Some(FileShellIntegration::Zsh),
"fish" => Some(FileShellIntegration::Fish),
_ => None,
}
}
fn shell_integration_paths() -> Result<&'static ShellIntegrationPaths> {
SHELL_INTEGRATION_PATHS
.get_or_init(|| create_shell_integration_paths().map_err(|error| format!("{error:#}")))
.as_ref()
.map_err(|message| anyhow::anyhow!("{message}"))
}
fn create_shell_integration_paths() -> Result<ShellIntegrationPaths> {
let root = std::env::temp_dir().join(format!(
"ashell-shell-integration-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
let zsh_dir = root.join("zsh");
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(&zsh_dir)
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
}
#[cfg(not(unix))]
fs::create_dir_all(&zsh_dir)
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
let bash_rc = root.join("bashrc");
write_shell_integration_file(&bash_rc, BASH_INTEGRATION_RC)?;
write_shell_integration_file(&zsh_dir.join(".zshenv"), ZSH_ENV_WRAPPER)?;
write_shell_integration_file(&zsh_dir.join(".zshrc"), ZSH_INTEGRATION_RC)?;
Ok(ShellIntegrationPaths { bash_rc, zsh_dir })
}
fn write_shell_integration_file(path: &Path, contents: &str) -> Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(path)
.with_context(|| format!("create shell integration file {}", path.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("write shell integration file {}", path.display()))
}
fn configure_file_shell_integration(
cmd: &mut CommandBuilder,
executable: &Path,
login_bash: bool,
) -> Result<bool> {
let Some(integration) = file_shell_integration(executable, login_bash) else {
return Ok(false);
};
match integration {
FileShellIntegration::Bash { login } => {
let paths = shell_integration_paths()?;
cmd.env("ASHELL_BASH_LOGIN", if login { "1" } else { "0" });
cmd.env("ASHELL_WINDOWS_GIT_BASH", if login { "1" } else { "0" });
cmd.arg("--rcfile");
cmd.arg(paths.bash_rc.as_os_str());
cmd.arg("-i");
}
FileShellIntegration::Zsh => {
let paths = shell_integration_paths()?;
if let Some(original_zdotdir) = std::env::var_os("ZDOTDIR") {
cmd.env("ASHELL_ORIGINAL_ZDOTDIR", original_zdotdir);
}
cmd.env("ASHELL_INTEGRATION_ZDOTDIR", paths.zsh_dir.as_os_str());
cmd.env("ZDOTDIR", paths.zsh_dir.as_os_str());
}
FileShellIntegration::Fish => {
cmd.args(["-C", FISH_INTEGRATION_COMMAND]);
}
}
cmd.env("ASHELL_SHELL_INTEGRATION", "1");
Ok(true)
}
#[cfg(any(windows, test))]
fn command_prompt_with_shell_integration(original_prompt: &str) -> String {
format!("\x1b]133;D\x07\x1b]133;A\x07\x1b]0;ASHELL_CWD:$P\x07{original_prompt}\x1b]133;B\x07")
}
#[cfg(not(windows))]
fn local_process_directory(system: &mut System, pid: Pid) -> Option<std::path::PathBuf> {
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
false,
ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always),
);
system
.process(pid)
.and_then(|process| process.cwd())
.map(std::path::PathBuf::from)
}
#[derive(Debug, Clone)]
pub struct LocalTerminalShellLaunch {
pub executable: PathBuf,
}
/// Resolves a configured local shell to an executable available on this host.
pub fn resolve_local_terminal_shell(
shell: LocalTerminalShell,
) -> anyhow::Result<LocalTerminalShellLaunch> {
#[cfg(not(windows))]
let _ = shell;
#[cfg(windows)]
{
let executable = match shell {
LocalTerminalShell::WindowsPowerShell => resolve_windows_powershell(),
LocalTerminalShell::PowerShell7 => resolve_powershell7(),
LocalTerminalShell::CommandPrompt => resolve_command_prompt(),
LocalTerminalShell::GitBash => resolve_git_bash(),
}
.ok_or_else(|| anyhow::anyhow!("configured local shell is not installed: {shell:?}"))?;
return Ok(LocalTerminalShellLaunch { executable });
}
#[cfg(not(windows))]
{
let executable = std::env::var_os("SHELL")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/bin/zsh"));
Ok(LocalTerminalShellLaunch { executable })
}
}
pub fn local_terminal_shell_available(shell: LocalTerminalShell) -> bool {
resolve_local_terminal_shell(shell).is_ok()
}
#[cfg(windows)]
fn existing_file(path: PathBuf) -> Option<PathBuf> {
path.is_file().then_some(path)
}
#[cfg(windows)]
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name).map(PathBuf::from)
}
#[cfg(windows)]
fn find_on_path(name: &str, predicate: impl Fn(&Path) -> bool) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|directory| directory.join(name))
.find(|candidate| candidate.is_file() && predicate(candidate))
}
#[cfg(windows)]
fn resolve_windows_powershell() -> Option<PathBuf> {
let system_root = env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"))
.or_else(|| find_on_path("powershell.exe", |_| true))
}
#[cfg(windows)]
fn resolve_powershell7() -> Option<PathBuf> {
let mut roots = Vec::new();
if let Some(program_files) = env_path("ProgramFiles") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramW6432") {
roots.push(program_files.join("PowerShell"));
}
if let Some(program_files) = env_path("ProgramFiles(x86)") {
roots.push(program_files.join("PowerShell"));
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
roots.push(local_app_data.join(r"Programs\PowerShell"));
}
let mut candidates = Vec::new();
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Microsoft\WindowsApps\pwsh.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\powershell\current\pwsh.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\powershell\current\pwsh.exe"));
}
for root in roots {
if let Some(candidate) = existing_file(root.join("pwsh.exe")) {
candidates.push(candidate);
}
let Ok(entries) = std::fs::read_dir(root) else {
continue;
};
let mut versions = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.chars()
.all(|character| character.is_ascii_digit() || character == '.')
})
.collect::<Vec<_>>();
versions.sort_by_key(|entry| entry.file_name());
candidates.extend(
versions
.into_iter()
.rev()
.filter_map(|entry| existing_file(entry.path().join("pwsh.exe"))),
);
}
candidates
.into_iter()
.next()
.or_else(|| find_on_path("pwsh.exe", |_| true))
}
#[cfg(windows)]
fn resolve_command_prompt() -> Option<PathBuf> {
env_path("ComSpec")
.and_then(existing_file)
.or_else(|| {
let system_root =
env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
existing_file(system_root.join(r"System32\cmd.exe"))
})
.or_else(|| find_on_path("cmd.exe", |_| true))
}
#[cfg(windows)]
fn resolve_git_bash() -> Option<PathBuf> {
let mut candidates = Vec::new();
for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
if let Some(program_files) = env_path(variable) {
candidates.push(program_files.join(r"Git\bin\bash.exe"));
}
}
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
candidates.push(local_app_data.join(r"Programs\Git\bin\bash.exe"));
}
if let Some(scoop) = env_path("SCOOP") {
candidates.push(scoop.join(r"apps\git\current\bin\bash.exe"));
}
if let Some(user_profile) = env_path("USERPROFILE") {
candidates.push(user_profile.join(r"scoop\apps\git\current\bin\bash.exe"));
}
candidates.into_iter().find_map(existing_file).or_else(|| {
find_on_path("bash.exe", |candidate| {
let path = candidate.to_string_lossy().to_ascii_lowercase();
path.contains("\\git\\") || path.contains("/git/")
})
})
}
pub fn spawn_local_terminal_at(
tab_id: String,
cols: u16,
rows: u16,
events: Sender<BackendEvent>,
events: GuardedBackendEventSender,
initial_directory: Option<&Path>,
shell: LocalTerminalShell,
) -> Result<BackendTx> {
let pty_system = native_pty_system();
let pair = pty_system
@@ -25,15 +486,51 @@ pub fn spawn_local_terminal(
})
.context("open local PTY")?;
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
if cfg!(windows) {
"powershell.exe".into()
} else {
"/bin/zsh".into()
let launch = resolve_local_terminal_shell(shell).context("resolve local shell")?;
let mut cmd = CommandBuilder::new(&launch.executable);
#[cfg(windows)]
{
match shell {
LocalTerminalShell::WindowsPowerShell | LocalTerminalShell::PowerShell7 => {
cmd.args([
"-NoLogo",
"-NoExit",
"-Command",
POWERSHELL_SHELL_INTEGRATION,
]);
}
LocalTerminalShell::CommandPrompt => {
let original_prompt =
std::env::var("PROMPT").unwrap_or_else(|_| "$P$G".to_string());
cmd.args(["/Q"]);
cmd.env(
"PROMPT",
command_prompt_with_shell_integration(&original_prompt),
);
}
LocalTerminalShell::GitBash => {
cmd.env("CHERE_INVOKING", "1");
}
}
});
let mut cmd = CommandBuilder::new(&shell);
}
let login_bash = cfg!(windows) && shell == LocalTerminalShell::GitBash;
let shell_integration_configured =
match configure_file_shell_integration(&mut cmd, &launch.executable, login_bash) {
Ok(configured) => configured,
Err(error) => {
tracing::warn!(
"[local] shell integration unavailable for {}: {error:#}",
launch.executable.display()
);
false
}
};
#[cfg(windows)]
if shell == LocalTerminalShell::GitBash && !shell_integration_configured {
cmd.args(["--login", "-i"]);
}
#[cfg(not(windows))]
let _ = shell_integration_configured;
cmd.env(
"TERM",
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
@@ -54,8 +551,13 @@ pub fn spawn_local_terminal(
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
cmd.env("SHELL", shell);
if let Some(directory) = initial_directory.filter(|path| path.is_dir()) {
cmd.cwd(directory.as_os_str());
}
cmd.env("SHELL", launch.executable.as_os_str());
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
#[cfg(not(windows))]
let child_pid = child.process_id().map(Pid::from_u32);
drop(pair.slave);
let master = pair.master;
@@ -94,8 +596,15 @@ pub fn spawn_local_terminal(
let write_tab = tab_id.clone();
let write_events = events.clone();
thread::spawn(move || {
#[cfg(not(windows))]
let mut process_system = System::new();
#[cfg(not(windows))]
let mut last_directory = None;
#[cfg(not(windows))]
let mut last_directory_check = None;
loop {
match cmd_rx.recv_timeout(std::time::Duration::from_millis(100)) {
match cmd_rx.recv_timeout(Duration::from_millis(100)) {
Ok(command) => match command {
BackendCommand::Input(bytes) => {
if let Err(err) = writer.write_all(&bytes) {
@@ -116,7 +625,10 @@ pub fn spawn_local_terminal(
});
}
BackendCommand::Close => break,
BackendCommand::SampleMetrics => {}
BackendCommand::SampleMetrics
| BackendCommand::SampleProcesses
| BackendCommand::SamplePorts
| BackendCommand::TerminateProcess { .. } => {}
},
Err(mpsc::RecvTimeoutError::Timeout) => {
if let Ok(Some(status)) = child.try_wait() {
@@ -129,6 +641,26 @@ pub fn spawn_local_terminal(
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
#[cfg(not(windows))]
{
if last_directory_check.is_none_or(|checked_at: Instant| {
checked_at.elapsed() >= DIRECTORY_POLL_INTERVAL
}) {
last_directory_check = Some(Instant::now());
if let Some(directory) =
child_pid.and_then(|pid| local_process_directory(&mut process_system, pid))
{
if last_directory.as_ref() != Some(&directory) {
last_directory = Some(directory.clone());
let _ = write_events.send(BackendEvent::LocalDirectoryChanged {
tab_id: write_tab.clone(),
path: directory,
});
}
}
}
}
}
let _ = child.kill();
});
@@ -140,3 +672,73 @@ pub fn spawn_local_terminal(
Ok(BackendTx::Local(cmd_tx))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{
BASH_INTEGRATION_RC, FISH_INTEGRATION_COMMAND, FileShellIntegration,
POWERSHELL_SHELL_INTEGRATION, ZSH_ENV_WRAPPER, ZSH_INTEGRATION_RC,
command_prompt_with_shell_integration, file_shell_integration,
};
#[test]
fn recognizes_supported_file_based_shells_on_unix_and_windows() {
assert_eq!(
file_shell_integration(Path::new("/bin/bash"), false),
Some(FileShellIntegration::Bash { login: false })
);
assert_eq!(
file_shell_integration(Path::new("bash.exe"), true),
Some(FileShellIntegration::Bash { login: true })
);
assert_eq!(
file_shell_integration(Path::new("/bin/zsh"), false),
Some(FileShellIntegration::Zsh)
);
assert_eq!(
file_shell_integration(Path::new("/opt/homebrew/bin/fish"), false),
Some(FileShellIntegration::Fish)
);
assert_eq!(file_shell_integration(Path::new("pwsh.exe"), false), None);
}
#[test]
fn shell_integrations_mark_prompt_and_command_boundaries() {
for integration in [
BASH_INTEGRATION_RC,
ZSH_INTEGRATION_RC,
FISH_INTEGRATION_COMMAND,
POWERSHELL_SHELL_INTEGRATION,
] {
assert!(integration.contains("133;A"));
assert!(integration.contains("133;B"));
assert!(integration.contains("133;C") || integration.contains("133;D"));
}
assert!(BASH_INTEGRATION_RC.contains("133;A;k=s"));
assert!(ZSH_INTEGRATION_RC.contains("133;A;k=s"));
assert!(POWERSHELL_SHELL_INTEGRATION.contains("133;A;k=s"));
let cmd_prompt = command_prompt_with_shell_integration("$P$G");
assert!(cmd_prompt.contains("133;A"));
assert!(cmd_prompt.contains("133;B"));
assert!(cmd_prompt.contains("ASHELL_CWD:$P"));
}
#[test]
fn startup_wrappers_load_user_configuration_before_installing_hooks() {
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bashrc"));
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bash_profile"));
assert!(ZSH_ENV_WRAPPER.contains("$ASHELL_USER_ZDOTDIR/.zshenv"));
assert!(ZSH_INTEGRATION_RC.contains("$ASHELL_USER_ZDOTDIR/.zshrc"));
}
#[test]
fn zsh_integration_restores_user_history_path() {
assert!(ZSH_INTEGRATION_RC.contains(r#"${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/*"#));
assert!(ZSH_INTEGRATION_RC.contains(
r#"HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}""#
));
}
}
+1
View File
@@ -1,2 +1,3 @@
pub mod local;
pub mod serial;
pub mod ssh;
+233
View File
@@ -0,0 +1,233 @@
use crate::session::config::Session;
use crate::terminal::{BackendCommand, BackendEvent, GuardedBackendEventSender};
use std::io::{Read, Write};
/// Spawn the serial port backend threads.
/// Returns a sender to send commands (like keyboard inputs) to the serial port.
pub fn spawn_serial_client(
_handle: &tokio::runtime::Handle,
tab_id: String,
session: Session,
events_tx: GuardedBackendEventSender,
) -> tokio::sync::mpsc::UnboundedSender<BackendCommand> {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<BackendCommand>();
let tab_id_clone = tab_id.clone();
let events_tx_clone = events_tx.clone();
std::thread::spawn(move || {
let _ = events_tx_clone.send(BackendEvent::Status {
tab_id: tab_id_clone.clone(),
text: rust_i18n::t!("starting_connection").to_string(),
});
let port_name = session.host;
let baud_rate = session.baud_rate;
tracing::info!(
"[serial] opening port {} at baud rate {}",
port_name,
baud_rate
);
let mut port_result = serialport::new(&port_name, baud_rate)
.timeout(std::time::Duration::from_millis(100))
.open();
if port_result.is_err() && baud_rate != 0 {
tracing::info!(
"[serial] failed to open port with baud rate {}, retrying with 0 (virtual port mode)",
baud_rate
);
port_result = serialport::new(&port_name, 0)
.timeout(std::time::Duration::from_millis(100))
.open();
}
let mut port = match port_result {
Ok(p) => p,
Err(e) => {
tracing::error!("[serial] failed to open port {}: {}", port_name, e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to open serial port {port_name}: {e}"),
});
return;
}
};
let mut port_write = match port.try_clone() {
Ok(pw) => pw,
Err(e) => {
tracing::error!("[serial] failed to clone port: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Failed to clone serial port: {e}"),
});
return;
}
};
// Notify connected
let _ = events_tx_clone.send(BackendEvent::Connected {
tab_id: tab_id_clone.clone(),
});
// Spawn write thread
let tab_id_write = tab_id_clone.clone();
let events_tx_write = events_tx_clone.clone();
std::thread::spawn(move || {
while let Some(cmd) = cmd_rx.blocking_recv() {
match cmd {
BackendCommand::Input(bytes) => {
if let Err(e) = port_write.write_all(&bytes) {
tracing::error!("[serial] write error: {}", e);
let _ = events_tx_write.send(BackendEvent::Closed {
tab_id: tab_id_write.clone(),
reason: format!("Serial write error: {e}"),
});
break;
}
let _ = port_write.flush();
}
BackendCommand::Close => break,
BackendCommand::Resize { .. }
| BackendCommand::SampleMetrics
| BackendCommand::SampleProcesses
| BackendCommand::SamplePorts
| BackendCommand::TerminateProcess { .. } => {}
}
}
});
// Read loop in current thread
let mut buf = [0u8; 1024];
let mut last_was_cr = false;
loop {
match port.read(&mut buf) {
Ok(n) if n > 0 => {
let mut processed = Vec::with_capacity(n * 2);
let read_bytes = &buf[..n];
for i in 0..n {
let b = read_bytes[i];
if b == b'\n' {
let prev_was_cr = if i > 0 {
read_bytes[i - 1] == b'\r'
} else {
last_was_cr
};
if !prev_was_cr {
processed.push(b'\r');
}
}
processed.push(b);
}
last_was_cr = read_bytes[n - 1] == b'\r';
let _ = events_tx_clone.send(BackendEvent::Output {
tab_id: tab_id_clone.clone(),
bytes: processed,
});
}
Ok(_) => {}
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {}
Err(e) => {
tracing::info!("[serial] port read error/closed: {}", e);
let _ = events_tx_clone.send(BackendEvent::Closed {
tab_id: tab_id_clone,
reason: format!("Serial read error: {e}"),
});
break;
}
}
}
});
cmd_tx
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use portable_pty::{NativePtySystem, PtySize, PtySystem};
#[tokio::test]
async fn test_serial_read_write_simulation() {
// 1. Create a PTY pair using portable-pty to simulate a serial device
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
// On macOS/Linux, the slave name path behaves like a TTY device.
let fd = pair.master.as_raw_fd().unwrap();
let slave_name = unsafe {
let ptr = libc::ptsname(fd);
assert!(!ptr.is_null());
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
};
println!("Simulating serial device on PTY slave path: {}", slave_name);
// 2. Spawn the serial backend targeting the PTY slave path
let (events_tx, events_rx) = std::sync::mpsc::channel();
let handle = tokio::runtime::Handle::current();
let session = Session::serial(slave_name, 0);
let backend_events = GuardedBackendEventSender::new(events_tx);
let cmd_tx = spawn_serial_client(&handle, "test-tab".to_string(), session, backend_events);
// Wait for the Status event
let status_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Status event")
.into_current();
match status_event {
Some(BackendEvent::Status { tab_id, .. }) => assert_eq!(tab_id, "test-tab"),
event => panic!("Expected Status event, got: {event:?}"),
}
// Wait for the Connected event
let connected_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Connected event")
.into_current();
match connected_event {
Some(BackendEvent::Connected { tab_id }) => assert_eq!(tab_id, "test-tab"),
event => panic!("Expected Connected event, got: {event:?}"),
}
// 3. Test Reading: Write to PTY master, verify serial backend outputs it to UI
let mut master_writer = pair.master.take_writer().unwrap();
master_writer.write_all(b"hello serial simulator").unwrap();
master_writer.flush().unwrap();
let output_event = events_rx
.recv_timeout(std::time::Duration::from_secs(2))
.expect("Failed to receive Output event")
.into_current();
match output_event {
Some(BackendEvent::Output { tab_id, bytes }) => {
assert_eq!(tab_id, "test-tab");
assert_eq!(bytes, b"hello serial simulator");
}
event => panic!("Expected Output event, got: {event:?}"),
}
// 4. Test Writing: Send BackendCommand::Input to backend, verify PTY master reads it
cmd_tx
.send(BackendCommand::Input(b"world serial simulator".to_vec()))
.unwrap();
let mut master_reader = pair.master.try_clone_reader().unwrap();
let mut read_buf = [0u8; 128];
let bytes_read = master_reader.read(&mut read_buf).unwrap();
assert_eq!(&read_buf[..bytes_read], b"world serial simulator");
// 5. Clean up
cmd_tx.send(BackendCommand::Close).unwrap();
}
}
+729 -124
View File
@@ -5,18 +5,28 @@ use std::{
use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use base64::Engine as _;
use directories::BaseDirs;
use russh::{
ChannelMsg, Disconnect,
client::{self, Handler},
keys::{HashAlg, PrivateKey, decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key},
keys::{PrivateKey, decode_secret_key, load_secret_key},
};
use tokio::sync::mpsc;
use crate::{
session::config::{AuthMethod, Session},
system::{SystemSnapshot, remote_snapshot_from_kv},
terminal::{BackendCommand, BackendEvent, BackendTx},
session::{
config::{AuthMethod, Session},
ssh_keys::{
authenticate_with_default_keys, normalize_inline_private_key, private_keys_with_algs,
session_has_explicit_key,
},
},
system::{
RemotePort, RemoteProcess, SystemSnapshot, remote_ports_from_probe,
remote_processes_from_ps, remote_snapshot_from_kv,
},
terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender},
};
pub fn spawn_ssh_terminal(
@@ -25,7 +35,7 @@ pub fn spawn_ssh_terminal(
session: Session,
cols: u16,
rows: u16,
events: std::sync::mpsc::Sender<BackendEvent>,
events: GuardedBackendEventSender,
) -> BackendTx {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
let task_tab = tab_id.clone();
@@ -52,30 +62,213 @@ pub fn spawn_ssh_terminal(
async fn sample_remote_system_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<SystemSnapshot> {
let unix_result = async {
let output = execute_remote_command_with_handle(
handle.clone(),
REMOTE_SYSTEM_PROBE,
"Unix remote metrics probe",
)
.await?;
remote_snapshot_from_kv(&output).context("parse Unix remote metrics probe")
}
.await;
match unix_result {
Ok(snapshot) => Ok(snapshot),
Err(unix_error) => {
let command = powershell_encoded_command(REMOTE_WINDOWS_SYSTEM_PROBE);
let output = execute_remote_command_with_handle(
handle,
&command,
"Windows remote metrics probe",
)
.await
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))?;
remote_snapshot_from_kv(&output)
.context("parse Windows remote metrics probe")
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))
}
}
}
async fn execute_remote_command_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
command: &str,
operation: &str,
) -> Result<String> {
let mut channel = handle
.lock()
.await
.channel_open_session()
.await
.context("open metrics session")?;
.with_context(|| format!("open {operation} session"))?;
channel
.exec(true, REMOTE_SYSTEM_PROBE)
.exec(true, command)
.await
.context("exec remote metrics probe")?;
.with_context(|| format!("execute {operation}"))?;
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);
let mut output = Vec::new();
let mut exit_status = None;
let wait_result = tokio::time::timeout(std::time::Duration::from_secs(20), async {
while let Some(msg) = channel.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
output.extend_from_slice(&data);
}
ChannelMsg::ExitStatus {
exit_status: status,
} => exit_status = Some(status),
ChannelMsg::Close => break,
_ => {}
}
ChannelMsg::Close => break,
_ => {}
}
})
.await;
if wait_result.is_err() {
return Err(anyhow!("{operation} timed out after 20 seconds"));
}
let output = String::from_utf8_lossy(&stdout);
remote_snapshot_from_kv(&output)
let output = String::from_utf8_lossy(&output).trim().to_string();
if exit_status.is_none_or(|status| status != 0) {
let detail = if output.is_empty() {
exit_status.map_or_else(
|| "remote command closed without an exit status".to_string(),
|status| format!("exit status {status}"),
)
} else {
output
};
return Err(anyhow!("{operation} failed: {detail}"));
}
Ok(output)
}
async fn sample_remote_processes_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<Vec<RemoteProcess>> {
let unix_result = async {
let output = execute_remote_command_with_handle(
handle.clone(),
REMOTE_PROCESS_PROBE,
"Unix remote process probe",
)
.await?;
parse_remote_process_probe(&output, "Unix remote process probe")
}
.await;
match unix_result {
Ok(processes) => Ok(processes),
Err(unix_error) => {
let command = powershell_encoded_command(REMOTE_WINDOWS_PROCESS_PROBE);
let output = execute_remote_command_with_handle(
handle,
&command,
"Windows remote process probe",
)
.await
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))?;
parse_remote_process_probe(&output, "Windows remote process probe")
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))
}
}
}
fn parse_remote_process_probe(output: &str, operation: &str) -> Result<Vec<RemoteProcess>> {
let processes = remote_processes_from_ps(output);
if !processes.is_empty() {
return Ok(processes);
}
let detail = output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("empty output");
Err(anyhow!(
"{operation} returned no parseable process rows: {detail}"
))
}
async fn sample_remote_ports_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
) -> Result<Vec<RemotePort>> {
let unix_result = async {
let output = execute_remote_command_with_handle(
handle.clone(),
REMOTE_PORT_PROBE,
"Unix remote port probe",
)
.await?;
parse_remote_port_probe(&output, "Unix remote port probe")
}
.await;
match unix_result {
Ok(ports) => Ok(ports),
Err(unix_error) => {
let command = powershell_encoded_command(REMOTE_WINDOWS_PORT_PROBE);
let output =
execute_remote_command_with_handle(handle, &command, "Windows remote port probe")
.await
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))?;
parse_remote_port_probe(&output, "Windows remote port probe")
.with_context(|| format!("Unix probe failed first: {unix_error:#}"))
}
}
}
fn parse_remote_port_probe(output: &str, operation: &str) -> Result<Vec<RemotePort>> {
let ports = remote_ports_from_probe(output);
if !ports.is_empty() {
return Ok(ports);
}
if output.trim().is_empty() {
return Ok(Vec::new());
}
let detail = output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or("empty output");
Err(anyhow!(
"{operation} returned no parseable port rows: {detail}"
))
}
async fn terminate_remote_process_with_handle(
handle: Arc<tokio::sync::Mutex<russh::client::Handle<ClientHandler>>>,
pid: u32,
) -> Result<()> {
if pid <= 1 {
return Err(anyhow!("refusing to terminate protected PID {pid}"));
}
if let Err(unix_error) = execute_remote_command_with_handle(
handle.clone(),
&format!("kill -TERM {pid}"),
"terminate Unix remote process",
)
.await
{
let command = powershell_encoded_command(&format!(
"$ErrorActionPreference = 'Stop'; Stop-Process -Id {pid} -ErrorAction Stop"
));
execute_remote_command_with_handle(handle, &command, "terminate Windows remote process")
.await
.with_context(|| format!("Unix termination failed first: {unix_error:#}"))?;
}
Ok(())
}
fn powershell_encoded_command(script: &str) -> String {
let utf16_le = script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
let encoded = base64::engine::general_purpose::STANDARD.encode(utf16_le);
format!("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}")
}
async fn run_ssh(
@@ -84,7 +277,7 @@ async fn run_ssh(
cols: u16,
rows: u16,
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
events: std::sync::mpsc::Sender<BackendEvent>,
events: GuardedBackendEventSender,
) -> Result<()> {
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.clone(),
@@ -156,6 +349,82 @@ async fn run_ssh(
}
});
}
Some(BackendCommand::SampleProcesses) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match sample_remote_processes_with_handle(handle_clone).await {
Ok(processes) => {
let _ = events_clone.send(BackendEvent::RemoteProcesses {
tab_id: tab_id_clone,
processes,
});
}
Err(err) => {
let _ = events_clone.send(
BackendEvent::RemoteProcessesUnavailable {
tab_id: tab_id_clone,
reason: format!(
"remote process list unavailable: {err:#}"
),
},
);
}
}
});
}
Some(BackendCommand::SamplePorts) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match sample_remote_ports_with_handle(handle_clone).await {
Ok(ports) => {
let _ = events_clone.send(BackendEvent::RemotePorts {
tab_id: tab_id_clone,
ports,
});
}
Err(err) => {
let _ = events_clone.send(
BackendEvent::RemotePortsUnavailable {
tab_id: tab_id_clone,
reason: format!(
"remote port list unavailable: {err:#}"
),
},
);
}
}
});
}
Some(BackendCommand::TerminateProcess { pid }) => {
let handle_clone = handle.clone();
let tab_id_clone = tab_id.clone();
let events_clone = events.clone();
tokio::spawn(async move {
match terminate_remote_process_with_handle(handle_clone, pid).await {
Ok(()) => {
let _ = events_clone.send(
BackendEvent::RemoteProcessTerminated {
tab_id: tab_id_clone,
pid,
},
);
}
Err(err) => {
let _ = events_clone.send(
BackendEvent::RemoteProcessTerminateFailed {
tab_id: tab_id_clone,
pid,
reason: format!("{err:#}"),
},
);
}
}
});
}
Some(BackendCommand::Close) | None => {
tracing::info!("[ssh] local client closed the session for tab {}", tab_id);
let _ = channel.eof().await;
@@ -216,26 +485,27 @@ async fn run_ssh(
async fn connect_and_authenticate(
tab_id: &str,
session: &Session,
events: &std::sync::mpsc::Sender<BackendEvent>,
events: &GuardedBackendEventSender,
) -> 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(3)),
keepalive_max: 2,
..Default::default()
});
let config = Arc::new(crate::session::config::ssh_client_config());
let addr = format!("{}:{}", session.host, session.port);
tracing::info!(
"[ssh] initiating tcp connection to {} (user: {})",
addr,
session.user
);
let status_text = if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
format!("connecting to {addr} via {} proxy {}:{}", ptype.to_uppercase(), phost, pport_val)
} else {
format!("opening tcp connection to {addr}")
};
let status_text =
if let Some((ptype, phost, pport)) = crate::session::config::active_proxy(session) {
let pport_val = pport.unwrap_or_else(|| if ptype == "http" { 8080 } else { 1080 });
format!(
"connecting to {addr} via {} proxy {}:{}",
ptype.to_uppercase(),
phost,
pport_val
)
} else {
format!("opening tcp connection to {addr}")
};
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: status_text,
@@ -267,7 +537,12 @@ async fn connect_and_authenticate(
.context("password authentication failed")?
}
AuthMethod::Key => {
let source = key_source_label(session);
let has_explicit_key = session_has_explicit_key(session);
let source = if has_explicit_key {
key_source_label(session)
} else {
"~/.ssh/ default keys".to_string()
};
tracing::info!(
"[ssh] sending key authentication for {}@{} (key source: {})",
session.user,
@@ -276,43 +551,141 @@ async fn connect_and_authenticate(
);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!("connected to {addr}, loading private key from {source}"),
text: if has_explicit_key {
format!("connected to {addr}, loading private key from {source}")
} else {
format!(
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
session.user
)
},
});
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 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;
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
if has_explicit_key {
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 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
} else {
let success =
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
if !success {
return Err(anyhow::anyhow!(
"public key authentication failed for {}@{}:{} - no valid default key found in ~/.ssh/",
session.user,
session.host,
session.port
));
}
success
}
if !success {
return Err(anyhow::anyhow!(
"public key authentication failed for {}@{}:{} using {} ({})",
session.user,
session.host,
session.port,
source,
algorithm
));
}
AuthMethod::Config => {
// SSH Config auth: try the identity file from config, or default keys
let source = key_source_label(session);
tracing::info!(
"[ssh] sending ssh-config 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}"),
});
// If an explicit key path is set from the SSH config IdentityFile, use it;
// otherwise try default keys from ~/.ssh/
// Note: for Config auth, we never use inline key content
let has_explicit_key = !session.private_key_path.trim().is_empty();
if has_explicit_key {
let keypair = load_session_private_key(session)?;
let algorithm = format!("{:?}", keypair.algorithm());
let keys = private_keys_with_algs(keypair).context("invalid private key")?;
let mut success = false;
for key in keys {
match handle.authenticate_publickey(&session.user, key).await {
Ok(true) => {
success = true;
break;
}
Ok(false) => {
continue;
}
Err(_) => {
continue;
}
}
}
if !success {
return Err(anyhow::anyhow!(
"ssh-config key authentication failed for {}@{}:{} using {} ({})",
session.user,
session.host,
session.port,
source,
algorithm
));
}
success
} else {
let passphrase = session.passphrase.trim();
let passphrase = (!passphrase.is_empty()).then_some(passphrase);
let _ = events.send(BackendEvent::Status {
tab_id: tab_id.to_string(),
text: format!(
"connected to {addr}, trying default keys from ~/.ssh/ for {}",
session.user
),
});
let success =
authenticate_with_default_keys(&mut handle, &session.user, passphrase).await?;
if !success {
return Err(anyhow::anyhow!(
"ssh-config authentication failed for {}@{}:{} - no valid default key found",
session.user,
session.host,
session.port
));
}
success
}
success
}
};
@@ -335,6 +708,10 @@ async fn connect_and_authenticate(
session.port,
key_source_label(session)
),
AuthMethod::Config => format!(
"authentication failed: server rejected ssh-config authentication for {}@{}:{}",
session.user, session.host, session.port
),
}
));
}
@@ -387,47 +764,6 @@ fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
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;
@@ -454,10 +790,12 @@ fn key_source_label(session: &Session) -> String {
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
LC_ALL=C
export LC_ALL
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; }
cpu_stat() { awk '"'"'/^cpu / { total = ($2+$3+$4+$5+$6+$7+$8); printf "%.0f %.0f\n", total, $5 }'"'"' /proc/stat 2>/dev/null; }
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { printf "%.0f %.0f\n", rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
read cpu_total_1 cpu_idle_1 <<EOF
$(cpu_stat)
@@ -473,22 +811,62 @@ EOF
$(net_stat)
EOF
cpu_total_1=${cpu_total_1:-0}
cpu_idle_1=${cpu_idle_1:-0}
cpu_total_2=${cpu_total_2:-0}
cpu_idle_2=${cpu_idle_2:-0}
net_rx_1=${net_rx_1:-0}
net_tx_1=${net_tx_1:-0}
net_rx_2=${net_rx_2:-0}
net_tx_2=${net_tx_2:-0}
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)
# metrics normalization
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN {
if (total <= 0) {
print "0.00"
} else {
usage = ((total-idle)/total)*100
if (usage < 0) usage = 0
if (usage > 100) usage = 100
printf "%.2f", usage
}
}'"'"')
mem_available=$(awk '"'"'
/^MemTotal:/ { total = $2 * 1024 }
/^MemAvailable:/ { available = $2 * 1024; has_available = 1 }
/^MemFree:/ { free = $2 * 1024 }
/^Buffers:/ { buffers = $2 * 1024 }
/^Cached:/ { cached = $2 * 1024 }
/^SReclaimable:/ { reclaimable = $2 * 1024 }
/^Shmem:/ { shmem = $2 * 1024 }
END {
if (!has_available) available = free + buffers + cached + reclaimable - shmem
if (available < 0) available = 0
if (total > 0 && available > total) available = total
printf "%.0f\n", available
}
'"'"' /proc/meminfo 2>/dev/null)
mem_total=$(awk '"'"'/^MemTotal:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
swap_total=$(awk '"'"'/^SwapTotal:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
swap_free=$(awk '"'"'/^SwapFree:/ {printf "%.0f\n", $2 * 1024; exit}'"'"' /proc/meminfo 2>/dev/null)
mem_used=$(( ${mem_total:-0} - ${mem_available:-0} ))
[ "$mem_used" -lt 0 ] && mem_used=0
swap_used=$(( ${swap_total:-0} - ${swap_free:-0} ))
[ "$swap_used" -lt 0 ] && swap_used=0
net_rx=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))
[ "$net_rx" -lt 0 ] && net_rx=0
net_tx=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))
[ "$net_tx" -lt 0 ] && net_tx=0
echo "CPU_PERCENT=${cpu_percent:-0.00}"
echo "MEM_TOTAL=${mem_total:-0}"
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
echo "MEM_USED=$mem_used"
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
echo "SWAP_USED=$swap_used"
echo "NET_RX=$net_rx"
echo "NET_TX=$net_tx"
LC_ALL=C df -kP 2>/dev/null | awk "NR > 1 && \$1 !~ /^(tmpfs|devtmpfs|ramfs|overlay|aufs)\$/ { printf \"DISK=%s\t%.0f\t%.0f\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 6
exit 0
fi
@@ -531,15 +909,242 @@ EOF
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"
echo "unsupported Unix remote operating system: $os" >&2
exit 2
'"#;
const REMOTE_PROCESS_PROBE: &str = r#"sh -lc '
os=$(uname -s 2>/dev/null || echo unknown)
LC_ALL=C
export LC_ALL
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
before=$(mktemp "${TMPDIR:-/tmp}/ashell-process-before.XXXXXX") || exit 1
after=$(mktemp "${TMPDIR:-/tmp}/ashell-process-after.XXXXXX") || { rm -f "$before"; exit 1; }
trap '"'"'rm -f "$before" "$after"'"'"' EXIT HUP INT TERM
hz=$(getconf CLK_TCK 2>/dev/null || echo 100)
page_size=$(getconf PAGESIZE 2>/dev/null || echo 4096)
capture_processes() {
for process_stat in /proc/[0-9]*/stat; do
[ -r "$process_stat" ] || continue
process_pid=${process_stat#/proc/}
process_pid=${process_pid%/stat}
process_line=$(cat "$process_stat" 2>/dev/null) || continue
process_rest=${process_line##*) }
set -- $process_rest
[ "$#" -ge 22 ] || continue
process_ticks=$((${12} + ${13}))
process_start=${20}
process_rss_pages=${22}
[ "$process_rss_pages" -ge 0 ] 2>/dev/null || process_rss_pages=0
process_memory=$((process_rss_pages * page_size))
process_user=$(stat -c %U "/proc/$process_pid" 2>/dev/null || echo "-")
process_command=$(tr '"'"'\000\t\r\n'"'"' '"'"' '"'"' < "/proc/$process_pid/cmdline" 2>/dev/null)
if [ -z "$process_command" ]; then
process_command=$(tr '"'"'\t\r\n'"'"' '"'"' '"'"' < "/proc/$process_pid/comm" 2>/dev/null)
fi
[ -n "$process_command" ] || process_command="[$process_pid]"
printf '"'"'%s\t%s\t%s\t%s\t%s\t%s\n'"'"' "$process_pid" "$process_user" "$process_ticks" "$process_start" "$process_memory" "$process_command"
done
}
capture_processes > "$before"
sleep 1
capture_processes > "$after"
awk -F '"'"'\t'"'"' -v hz="$hz" '"'"'
NR == FNR { ticks[$1] = $3; starts[$1] = $4; next }
{
delta = ($1 in ticks && starts[$1] == $4) ? ($3 - ticks[$1]) : 0;
cpu = (delta > 0 && hz > 0) ? delta * 100 / hz : 0;
printf "PROCESS\t%s\t%s\t%.2f\t%s\t%s\n", $1, $2, cpu, $5, $6;
}
'"'"' "$before" "$after"
exit 0
fi
if [ "$os" = "Darwin" ]; then
top_output=$(mktemp "${TMPDIR:-/tmp}/ashell-process-top.XXXXXX") || exit 1
trap '"'"'rm -f "$top_output"'"'"' EXIT HUP INT TERM
LC_ALL=C top -l 2 -s 1 -n 10000 -stats pid,cpu > "$top_output" 2>/dev/null || exit 1
LC_ALL=C ps -axo pid=,user=,rss=,command= 2>/dev/null | awk -v top_output="$top_output" '"'"'
BEGIN {
sample = 0;
while ((getline line < top_output) > 0) {
count = split(line, fields, /[[:space:]]+/);
start = fields[1] == "" ? 2 : 1;
if (fields[start] == "PID") { sample++; continue; }
if (sample == 2 && fields[start] ~ /^[0-9]+$/) {
value = fields[start + 1];
gsub(/%/, "", value);
cpu[fields[start]] = value + 0;
}
}
close(top_output);
}
{
pid = $1; user = $2; memory = $3 * 1024;
$1 = $2 = $3 = "";
sub(/^[[:space:]]+/, "");
printf "PROCESS\t%s\t%s\t%.2f\t%.0f\t%s\n", pid, user, cpu[pid] + 0, memory, $0;
}
'"'"'
exit 0
fi
echo "unsupported Unix remote operating system: $os" >&2
exit 2
'"#;
const REMOTE_PORT_PROBE: &str = r#"sh -lc '
LC_ALL=C
export LC_ALL
if command -v lsof >/dev/null 2>&1; then
lsof_output=$(
lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '"'"'
NR > 1 && $2 ~ /^[0-9]+$/ {
endpoint = $9;
state = $10;
gsub(/[()]/, "", state);
if (endpoint !~ /:[0-9]+$/) next;
port = endpoint;
sub(/^.*:/, "", port);
address = endpoint;
sub(/:[^:]*$/, "", address);
if (address == "") address = "*";
if (state == "") state = "LISTEN";
printf "PORT\tTCP\t%s\t%s\t%s\t%s\t%s\n", address, port, state, $2, $1;
}
'"'"'
lsof -nP -iUDP 2>/dev/null | awk '"'"'
NR > 1 && $2 ~ /^[0-9]+$/ {
endpoint = $9;
if (endpoint !~ /:[0-9]+$/) next;
port = endpoint;
sub(/^.*:/, "", port);
address = endpoint;
sub(/:[^:]*$/, "", address);
if (address == "") address = "*";
printf "PORT\tUDP\t%s\t%s\tUNCONN\t%s\t%s\n", address, port, $2, $1;
}
'"'"'
)
if [ -n "$lsof_output" ]; then
printf "%s\n" "$lsof_output"
exit 0
fi
fi
if command -v ss >/dev/null 2>&1; then
ss_output=$(ss -lntup 2>/dev/null | awk '"'"'
NF >= 5 {
protocol = $1;
state = $2;
endpoint = $5;
if (endpoint !~ /:[0-9]+$/) next;
port = endpoint;
sub(/^.*:/, "", port);
address = endpoint;
sub(/:[^:]*$/, "", address);
pid = "-";
process = "-";
for (i = 6; i <= NF; i++) {
token = $i;
if (token ~ /users:/) {
name = token;
sub(/^.*\(\("/, "", name);
sub(/".*$/, "", name);
if (name != "") process = name;
if (match(token, /pid=[0-9]+/)) {
pid = substr(token, RSTART + 4, RLENGTH - 4);
}
}
}
printf "PORT\t%s\t%s\t%s\t%s\t%s\t%s\n", protocol, address, port, state, pid, process;
}
'"'"'
)
if [ -n "$ss_output" ]; then
printf "%s\n" "$ss_output"
exit 0
fi
fi
if command -v netstat >/dev/null 2>&1; then
netstat -an 2>/dev/null | awk '"'"'
NR > 1 && NF >= 4 {
protocol = $1;
endpoint = $4;
state = protocol ~ /^udp/i ? "UNCONN" : "LISTEN";
if (endpoint !~ /:[0-9]+$/ && endpoint !~ /\.[0-9]+$/) next;
port = endpoint;
sub(/^.*[:.]/, "", port);
address = endpoint;
sub(/[:.][^:.]*$/, "", address);
if (address == "") address = "*";
if (protocol !~ /^udp/i && $NF != "LISTEN" && $NF != "LISTENING") next;
printf "PORT\t%s\t%s\t%s\t%s\t-\t-\n", protocol, address, port, state;
}
'"'"'
exit 0
fi
echo "no supported remote port utility found" >&2
exit 2
'"#;
const REMOTE_WINDOWS_SYSTEM_PROBE: &str = r#"$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average
$os = Get-CimInstance Win32_OperatingSystem
$memTotal = [uint64]$os.TotalVisibleMemorySize * 1024
$memFree = [uint64]$os.FreePhysicalMemory * 1024
$swapTotal = [uint64]$os.TotalVirtualMemorySize * 1024 - $memTotal
$swapFree = [uint64]$os.FreeVirtualMemory * 1024 - $memFree
$network = Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface
$netRx = ($network | Measure-Object -Property BytesReceivedPersec -Sum).Sum
$netTx = ($network | Measure-Object -Property BytesSentPersec -Sum).Sum
$cpuText = [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:F2}", [double]$cpu)
Write-Output ("CPU_PERCENT={0}" -f $cpuText)
Write-Output ("MEM_TOTAL={0}" -f $memTotal)
Write-Output ("MEM_USED={0}" -f ($memTotal - $memFree))
Write-Output ("SWAP_TOTAL={0}" -f ([Math]::Max(0, $swapTotal)))
Write-Output ("SWAP_USED={0}" -f ([Math]::Max(0, $swapTotal - $swapFree)))
Write-Output ("NET_RX={0}" -f ([uint64]$netRx))
Write-Output ("NET_TX={0}" -f ([uint64]$netTx))
Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
Write-Output ("DISK={0}`t{1}`t{2}" -f $_.DeviceID, [uint64]$_.FreeSpace, [uint64]$_.Size)
}"#;
const REMOTE_WINDOWS_PROCESS_PROBE: &str = r#"$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$before = @{}
Get-Process | ForEach-Object { if ($null -ne $_.CPU) { $before[$_.Id] = [double]$_.CPU } }
Start-Sleep -Seconds 1
Get-Process | ForEach-Object {
$previous = $before[$_.Id]
$cpu = if ($null -ne $previous -and $null -ne $_.CPU) { [Math]::Max(0, ([double]$_.CPU - $previous) * 100) } else { 0 }
$command = $_.ProcessName.Replace("`t", " ").Replace("`r", " ").Replace("`n", " ")
$cpuText = [string]::Format([Globalization.CultureInfo]::InvariantCulture, "{0:F2}", $cpu)
Write-Output ("PROCESS`t{0}`t-`t{1}`t{2}`t{3}" -f $_.Id, $cpuText, [uint64]$_.WorkingSet64, $command)
}"#;
const REMOTE_WINDOWS_PORT_PROBE: &str = r#"$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$tab = [char]9
$processNames = @{}
Get-Process | ForEach-Object { $processNames[[int]$_.Id] = $_.ProcessName }
@(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue) | ForEach-Object {
$ownerPid = [int]$_.OwningProcess
$processName = if ($processNames.ContainsKey($ownerPid)) { $processNames[$ownerPid] } else { '-' }
Write-Output ("PORT{0}TCP{0}{1}{0}{2}{0}LISTEN{0}{3}{0}{4}" -f $tab, $_.LocalAddress, $_.LocalPort, $ownerPid, $processName)
}
@(Get-NetUDPEndpoint -ErrorAction SilentlyContinue) | ForEach-Object {
$ownerPid = [int]$_.OwningProcess
$processName = if ($processNames.ContainsKey($ownerPid)) { $processNames[$ownerPid] } else { '-' }
Write-Output ("PORT{0}UDP{0}{1}{0}{2}{0}UNCONN{0}{3}{0}{4}" -f $tab, $_.LocalAddress, $_.LocalPort, $ownerPid, $processName)
}"#;
#[derive(Clone)]
struct ClientHandler;
+291
View File
@@ -0,0 +1,291 @@
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::{collections::VecDeque, sync::Mutex};
#[cfg(target_os = "macos")]
const APP_BUNDLE_ID: &str = "dev.ashell.app";
#[cfg(target_os = "windows")]
const APP_USER_MODEL_ID: &str = "dev.ashell.app";
#[cfg(any(target_os = "macos", target_os = "windows"))]
const MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS: usize = 64;
#[cfg(any(target_os = "macos", target_os = "windows"))]
static TERMINAL_NOTIFICATION_ACTIVATIONS: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
pub(crate) fn initialize() {
#[cfg(target_os = "macos")]
match desktop_notify::set_application(APP_BUNDLE_ID) {
Ok(()) => {
tracing::info!("registered the macOS notification application identity {APP_BUNDLE_ID}")
}
Err(error) => {
tracing::warn!(
"failed to register the macOS notification application identity {APP_BUNDLE_ID}: {error}"
);
}
}
#[cfg(target_os = "windows")]
if let Err(error) = unsafe {
windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(windows::core::w!(
"dev.ashell.app"
))
} {
tracing::warn!("failed to set Windows application identity: {error}");
}
}
pub(crate) fn show_terminal_notification(tab_id: String, title: String, body: String) {
#[cfg(any(target_os = "macos", target_os = "windows"))]
if let Err(error) = std::thread::Builder::new()
.name("desktop-notification".to_string())
.spawn(move || show_native_notification(&title, &body, &tab_id))
{
tracing::warn!("failed to start desktop notification thread: {error}");
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = (tab_id, title, body);
}
}
#[cfg(target_os = "macos")]
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
let result = mac_notification_sys::Notification::new()
.title(title)
.message(body)
.wait_for_click(true)
.send();
match result {
Ok(mac_notification_sys::NotificationResponse::Click) => {
queue_terminal_notification_activation(tab_id.to_string());
}
Ok(_) => tracing::info!("terminal notification closed without activation on macOS"),
Err(error) => tracing::warn!("failed to show macOS terminal notification: {error}"),
}
}
#[cfg(target_os = "windows")]
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
match show_windows_notification(APP_USER_MODEL_ID, title, body, tab_id) {
Ok(()) => tracing::info!("submitted terminal notification to Windows"),
Err(primary_error) => {
if let Err(fallback_error) = show_windows_notification(
winrt_notification::Toast::POWERSHELL_APP_ID,
title,
body,
tab_id,
) {
tracing::warn!(
"failed to show Windows terminal notification: {primary_error}; fallback failed: {fallback_error}"
);
}
}
}
}
#[cfg(target_os = "windows")]
fn show_windows_notification(
app_id: &str,
title: &str,
body: &str,
tab_id: &str,
) -> winrt_notification::Result<()> {
let activation_tab_id = tab_id.to_string();
winrt_notification::Toast::new(app_id)
.title(title)
.text2(body)
.duration(winrt_notification::Duration::Short)
.sound(None)
.on_activated(move || {
queue_terminal_notification_activation(activation_tab_id.clone());
Ok(())
})
.show()
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn queue_terminal_notification_activation(tab_id: String) {
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
tracing::warn!("failed to lock terminal notification activation queue");
return;
};
if activations.len() >= MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS {
activations.pop_front();
}
activations.push_back(tab_id);
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
tracing::warn!("failed to lock terminal notification activation queue");
return None;
};
activations.pop_front()
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
None
}
pub(crate) fn native_window_handle(window: &gpui::Window) -> Option<isize> {
#[cfg(target_os = "windows")]
{
use raw_window_handle::RawWindowHandle;
let handle = raw_window_handle::HasWindowHandle::window_handle(window).ok()?;
return match handle.as_raw() {
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get()),
_ => None,
};
}
#[cfg(not(target_os = "windows"))]
{
let _ = window;
None
}
}
pub(crate) fn set_unread_indicator(unread: bool, window_handle: Option<isize>) {
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use objc2_foundation::NSString;
let _ = window_handle;
let Some(main_thread) = MainThreadMarker::new() else {
tracing::warn!("attempted to update the Dock badge outside the main thread");
return;
};
let application = NSApplication::sharedApplication(main_thread);
let dock_tile = application.dockTile();
let label = unread.then(|| NSString::from_str("\u{2022}"));
dock_tile.setBadgeLabel(label.as_deref());
}
#[cfg(target_os = "windows")]
if let Some(window_handle) = window_handle {
if let Err(error) = set_windows_taskbar_badge(window_handle, unread) {
tracing::warn!("failed to update Windows taskbar badge: {error}");
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let _ = (unread, window_handle);
}
pub(crate) fn clear_unread_indicator(window_handle: Option<isize>) {
set_unread_indicator(false, window_handle);
}
#[allow(deprecated)]
/// Clears notifications delivered by the current application from Notification Center.
pub(crate) fn clear_current_app_delivered_notifications() {
#[cfg(target_os = "macos")]
{
use objc2_foundation::NSUserNotificationCenter;
// The default center is scoped to this application, so other apps' notifications remain untouched.
NSUserNotificationCenter::defaultUserNotificationCenter().removeAllDeliveredNotifications();
}
#[cfg(target_os = "windows")]
{
use windows::UI::Notifications::ToastNotificationManager;
if let Err(error) = ToastNotificationManager::History().and_then(|history| history.Clear())
{
tracing::warn!("failed to clear Windows notification history: {error}");
}
}
}
#[cfg(target_os = "windows")]
fn set_windows_taskbar_badge(window_handle: isize, unread: bool) -> windows::core::Result<()> {
use windows::{
Win32::{
Foundation::HWND,
System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
UI::{
Shell::{ITaskbarList3, TaskbarList},
WindowsAndMessaging::{
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
},
},
},
core::{IUnknown, PCWSTR, w},
};
let taskbar: ITaskbarList3 =
unsafe { CoCreateInstance(&TaskbarList, None::<&IUnknown>, CLSCTX_INPROC_SERVER)? };
unsafe { taskbar.HrInit()? };
let hwnd = HWND(window_handle);
if !unread {
return unsafe { taskbar.SetOverlayIcon(hwnd, HICON(0), PCWSTR::null()) };
}
let icon_resource = windows_badge_icon_resource();
let icon = unsafe {
CreateIconFromResourceEx(&icon_resource, true, 0x0003_0000, 16, 16, LR_DEFAULTCOLOR)?
};
let result = unsafe { taskbar.SetOverlayIcon(hwnd, icon, w!("Unread terminal notification")) };
let destroy_result = unsafe { DestroyIcon(icon) };
result?;
destroy_result
}
#[cfg(target_os = "windows")]
fn windows_badge_icon_resource() -> Vec<u8> {
const WIDTH: usize = 16;
const HEIGHT: usize = 16;
const BITMAP_HEADER_SIZE: u32 = 40;
const XOR_BYTES: usize = WIDTH * HEIGHT * 4;
const AND_ROW_BYTES: usize = 4;
let mut resource = Vec::with_capacity(BITMAP_HEADER_SIZE as usize + XOR_BYTES + 64);
resource.extend_from_slice(&BITMAP_HEADER_SIZE.to_le_bytes());
resource.extend_from_slice(&(WIDTH as i32).to_le_bytes());
resource.extend_from_slice(&((HEIGHT * 2) as i32).to_le_bytes());
resource.extend_from_slice(&1u16.to_le_bytes());
resource.extend_from_slice(&32u16.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
resource.extend_from_slice(&(XOR_BYTES as u32).to_le_bytes());
resource.extend_from_slice(&0i32.to_le_bytes());
resource.extend_from_slice(&0i32.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
resource.extend_from_slice(&0u32.to_le_bytes());
for y in (0..HEIGHT).rev() {
for x in 0..WIDTH {
if windows_badge_pixel_is_red(x, y) {
resource.extend_from_slice(&[42, 48, 239, 255]);
} else {
resource.extend_from_slice(&[0, 0, 0, 0]);
}
}
}
for y in (0..HEIGHT).rev() {
let mut row = [0u8; AND_ROW_BYTES];
for x in 0..WIDTH {
if !windows_badge_pixel_is_red(x, y) {
row[x / 8] |= 0x80 >> (x % 8);
}
}
resource.extend_from_slice(&row);
}
resource
}
#[cfg(target_os = "windows")]
fn windows_badge_pixel_is_red(x: usize, y: usize) -> bool {
let dx = x as f32 - 7.5;
let dy = y as f32 - 7.5;
dx * dx + dy * dy <= 42.25
}
+13 -6
View File
@@ -5,29 +5,33 @@ use gpui_component_assets::Assets;
mod app;
mod backend;
mod desktop_notification;
mod session;
mod sftp;
mod sync;
mod system;
mod terminal;
mod text_encoding;
rust_i18n::i18n!("locales", fallback = "en");
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
pub(crate) use app::keybinding_recorder::{
ClosePane, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, SplitPaneDown, SplitPaneLeft, SplitPaneRight,
SplitPaneUp, ToggleSftpZoom, ToggleSidebar, Copy, Paste,
ClosePane, Copy, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
OpenSession, OpenSettings, OpenTransfers, Paste, QuitApplication, SplitPaneDown, SplitPaneLeft,
SplitPaneRight, SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
};
pub(crate) use app::system_menu::{
AboutAshell, CloseWindow, MinimizeWindow, NewLocalTerminal, ToggleFullScreen, ZoomWindow,
};
pub(crate) use app::{
Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
};
pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup};
fn main() {
app::startup::sync_macos_launch_environment();
app::startup::init_logging();
desktop_notification::initialize();
#[cfg(target_os = "macos")]
let app = gpui_platform::application()
@@ -44,6 +48,9 @@ fn main() {
});
app.run(move |cx| {
gpui_component::init(cx);
app::system_menu::init(cx);
cx.on_action(|_: &QuitApplication, cx| cx.quit());
cx.bind_keys([
KeyBinding::new(
"tab",
+1397 -143
View File
File diff suppressed because it is too large Load Diff
+1777 -277
View File
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::BaseDirs;
/// A parsed entry from ~/.ssh/config
#[derive(Debug, Clone)]
pub struct SshConfigEntry {
/// The Host pattern (alias) from the config, e.g. "myserver"
pub host_alias: String,
/// The actual hostname (HostName), defaults to the host alias if not specified
pub hostname: String,
/// The user, defaults to empty (will use current OS user)
pub user: String,
/// The port, defaults to 22
pub port: u16,
/// Identity files specified for this host
pub identity_files: Vec<String>,
/// Whether this is a wildcard/pattern host (Host * or Host *)
pub is_wildcard: bool,
}
/// Parse ~/.ssh/config and return a list of concrete host entries.
/// Wildcard patterns (Host *) are excluded.
pub fn parse_ssh_config() -> Result<Vec<SshConfigEntry>> {
let config_path = ssh_config_path()?;
if !config_path.is_file() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
parse_ssh_config_content(&content)
}
fn ssh_config_path() -> Result<PathBuf> {
BaseDirs::new()
.map(|dirs| dirs.home_dir().join(".ssh/config"))
.context("failed to determine home directory")
}
/// Parse the content of an ssh config file into entries.
pub fn parse_ssh_config_content(content: &str) -> Result<Vec<SshConfigEntry>> {
let mut entries: Vec<SshConfigEntry> = Vec::new();
let mut current_host: Option<SshConfigEntry> = None;
for line in content.lines() {
let line = line.trim();
// Skip empty lines and comments
if line.is_empty() || line.starts_with('#') {
continue;
}
// Split into keyword and value
// OpenSSH supports both "keyword value" and "keyword=value" formats
let (keyword, value) = if let Some(pos) = line.find('=') {
(&line[..pos], line[pos + 1..].trim())
} else if let Some(pos) = line.find(' ') {
(&line[..pos], line[pos..].trim())
} else {
continue;
};
let keyword_lower = keyword.trim().to_lowercase();
let value = value.trim();
match keyword_lower.as_str() {
"host" => {
// Save previous entry if it exists and is not a wildcard
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
// Host line may contain multiple patterns (Host a b c)
// Take the first non-wildcard pattern as the display alias
let patterns: Vec<&str> = value.split_whitespace().collect();
if patterns.is_empty() {
continue;
}
let host_alias = patterns
.iter()
.find(|p| !p.contains('*') && !p.contains('?'))
.map(|p| p.to_string())
.unwrap_or_else(|| patterns[0].to_string());
let is_wildcard = value.contains('*') || value.contains('?');
current_host = Some(SshConfigEntry {
host_alias: host_alias.clone(),
hostname: host_alias,
user: String::new(),
port: 22,
identity_files: Vec::new(),
is_wildcard,
});
}
"hostname" => {
if let Some(entry) = current_host.as_mut() {
entry.hostname = value.to_string();
}
}
"user" => {
if let Some(entry) = current_host.as_mut() {
entry.user = value.to_string();
}
}
"port" => {
if let Some(entry) = current_host.as_mut() {
entry.port = value.parse::<u16>().unwrap_or(22);
}
}
"identityfile" => {
if let Some(entry) = current_host.as_mut() {
entry.identity_files.push(value.to_string());
}
}
// Skip Match blocks and Include directives — not supported yet
"match" | "include" => {
// If we encounter a Match block, flush the current Host entry
// since Match blocks apply to all subsequent hosts until the next Match/Host
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
// Don't create a new entry for Match/Include
}
_ => {}
}
}
// Save the last entry if it's not a wildcard
if let Some(entry) = current_host.take() {
if !entry.is_wildcard {
entries.push(entry);
}
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ssh_config_content() {
let content = r#"
Host myhost
HostName 1.2.3.4
User git
Port 2222
IdentityFile ~/.ssh/id_rsa
Host
Host =
Host anotherhost
HostName 5.6.7.8
"#;
let entries = parse_ssh_config_content(content).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].host_alias, "myhost");
assert_eq!(entries[0].hostname, "1.2.3.4");
assert_eq!(entries[0].user, "git");
assert_eq!(entries[0].port, 2222);
assert_eq!(entries[0].identity_files, vec!["~/.ssh/id_rsa".to_string()]);
assert_eq!(entries[1].host_alias, "anotherhost");
assert_eq!(entries[1].hostname, "5.6.7.8");
}
}
+96
View File
@@ -0,0 +1,96 @@
use std::sync::Arc;
use anyhow::{Result, anyhow};
use directories::BaseDirs;
use russh::{
client::{self, Handler},
keys::{HashAlg, PrivateKey, key::PrivateKeyWithHashAlg, load_secret_key},
};
use crate::session::config::Session;
pub const DEFAULT_KEY_NAMES: &[&str] = &["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"];
pub fn session_has_explicit_key(session: &Session) -> bool {
!session.private_key_path.trim().is_empty()
|| !normalize_inline_private_key(&session.private_key_inline).is_empty()
}
pub 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.is_empty() {
return String::new();
}
if !normalized.ends_with('\n') {
normalized.push('\n');
}
normalized
}
pub 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)
}
pub async fn authenticate_with_default_keys<H>(
handle: &mut client::Handle<H>,
user: &str,
passphrase: Option<&str>,
) -> Result<bool>
where
H: Handler + Send + Sync,
H::Error: Into<anyhow::Error>,
{
let Some(ssh_dir) = BaseDirs::new().map(|d| d.home_dir().join(".ssh")) else {
return Ok(false);
};
for key_name in DEFAULT_KEY_NAMES {
let key_path = ssh_dir.join(key_name);
if !key_path.exists() {
continue;
}
tracing::debug!("[ssh] trying default key {}", key_path.display());
match load_secret_key(&key_path, passphrase) {
Ok(keypair) => {
if let Ok(keys) = private_keys_with_algs(keypair) {
for key in keys {
match handle.authenticate_publickey(user, key).await {
Ok(true) => return Ok(true),
Ok(false) | Err(_) => continue,
}
}
}
}
Err(_) => continue,
}
}
Ok(false)
}
+538 -392
View File
File diff suppressed because it is too large Load Diff
+164 -34
View File
@@ -6,26 +6,6 @@ use crate::{
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
@@ -49,24 +29,154 @@ impl Ashell {
}
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
if let Some(handle) = self.active_sftp_handle() {
tracing::info!("[sftp] navigating to directory: '{}'", path);
handle.list_dir(path.clone());
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path;
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
let Some((current_path, home_dir)) = self
.active_sftp()
.map(|sftp| (sftp.current_path.clone(), sftp.home_dir.clone()))
else {
return;
};
let path = crate::sftp::normalize_remote_path(&path, &current_path, &home_dir);
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
tracing::info!("[sftp] navigating to directory: '{}'", path);
let ancestors = crate::sftp::remote_path_ancestors(&path);
let mut paths_to_load = Vec::new();
if let Some(sftp) = self.active_sftp_mut() {
sftp.current_path = path.clone();
sftp.selected_path = None;
sftp.selection_anchor = None;
sftp.preview = None;
sftp.selected_entries.clear();
sftp.expand_to(&path);
sftp.begin_directory_load(&path);
for ancestor in ancestors {
if ancestor != path
&& !sftp.directory_cache.contains_key(&ancestor)
&& !sftp.loading_directories.contains(&ancestor)
{
sftp.begin_directory_load(&ancestor);
paths_to_load.push(ancestor);
}
}
}
self.pending_sftp_path_sync = Some(path.clone());
for ancestor in paths_to_load {
handle.list_dir(ancestor);
}
handle.list_dir(path);
cx.notify();
}
pub(crate) fn toggle_sftp_tree_directory(&mut self, path: String, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let mut should_load = false;
if let Some(sftp) = self.active_sftp_mut() {
let has_error = sftp.directory_errors.contains_key(&path);
let has_cache = sftp.directory_cache.contains_key(&path);
if sftp.expanded_directories.contains(&path) && !has_error && has_cache {
sftp.expanded_directories.remove(&path);
cx.notify();
return;
}
sftp.expanded_directories.insert(path.clone());
should_load = (has_error || !has_cache) && !sftp.loading_directories.contains(&path);
if should_load {
sftp.begin_directory_load(&path);
}
}
if should_load {
handle.list_dir(path);
}
cx.notify();
}
pub(crate) fn reveal_current_sftp_directory(&mut self, cx: &mut Context<Self>) {
let Some(handle) = self.active_sftp_handle().cloned() else {
return;
};
let Some(current_path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) else {
return;
};
let ancestors = crate::sftp::remote_path_ancestors(&current_path);
let mut paths_to_load = Vec::new();
if let Some(sftp) = self.active_sftp_mut() {
sftp.expand_to(&current_path);
for path in ancestors {
if !sftp.directory_cache.contains_key(&path)
&& !sftp.loading_directories.contains(&path)
{
sftp.begin_directory_load(&path);
paths_to_load.push(path);
}
}
}
for path in paths_to_load {
handle.list_dir(path);
}
if let Some(index) = self.active_sftp().and_then(|sftp| {
sftp.tree_rows(self.show_hidden_files)
.iter()
.position(|row| row.path == current_path)
}) {
self.remote_tree_scroll_handle
.scroll_to_item(index, gpui::ScrollStrategy::Center);
}
cx.notify();
}
pub(crate) fn collapse_sftp_tree(&mut self, cx: &mut Context<Self>) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.collapse_all();
cx.notify();
}
}
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
pub(crate) fn select_sftp_entry(
&mut self,
entry: RemoteEntry,
visible_paths: &[String],
extend_range: bool,
cx: &mut Context<Self>,
) {
if extend_range {
if let Some(sftp) = self.active_sftp_mut() {
let target_path = entry.full_path.clone();
let target_index = visible_paths.iter().position(|path| path == &target_path);
let anchor_index = sftp
.selection_anchor
.as_ref()
.and_then(|anchor| visible_paths.iter().position(|path| path == anchor));
sftp.selected_entries.clear();
if let Some(target_index) = target_index {
let anchor_index = anchor_index.unwrap_or(target_index);
let start = anchor_index.min(target_index);
let end = anchor_index.max(target_index);
sftp.selected_entries
.extend(visible_paths[start..=end].iter().cloned());
} else {
sftp.selected_entries.insert(target_path.clone());
}
if sftp.selection_anchor.is_none() {
sftp.selection_anchor = Some(target_path.clone());
}
sftp.selected_path = Some(target_path);
}
cx.notify();
return;
}
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() {
sftp.selection_anchor = Some(entry.full_path.clone());
if !sftp.selected_entries.remove(&entry.full_path) {
sftp.selected_entries.insert(entry.full_path);
}
@@ -144,15 +254,27 @@ impl Ashell {
cx.notify();
}
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
pub(crate) fn trigger_sftp_context_edit(
&mut self,
window: &mut Window,
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();
tracing::info!("[sftp] opening in-app editor for: '{}'", menu.remote_path);
self.show_sftp_editor_dialog(menu.remote_path, window, cx);
}
pub(crate) fn trigger_sftp_context_rename(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.sftp_context_menu.take() else {
return;
};
self.show_sftp_rename_dialog(menu.remote_path, window, cx);
}
pub(crate) fn download_sftp_entry(
@@ -295,6 +417,8 @@ impl Ashell {
cx: &mut Context<Self>,
) {
if let Some(sftp) = self.active_sftp_mut() {
sftp.selected_path = Some(path.clone());
sftp.selection_anchor = Some(path.clone());
if checked {
sftp.selected_entries.insert(path);
} else {
@@ -307,12 +431,18 @@ impl Ashell {
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();
let paths: Vec<String> = sftp
.current_entries()
.iter()
.map(|entry| entry.full_path.clone())
.collect();
for path in paths {
sftp.selected_entries.insert(path);
}
} else {
sftp.selected_entries.clear();
sftp.selected_path = None;
sftp.selection_anchor = None;
}
cx.notify();
}
+214 -33
View File
@@ -22,6 +22,25 @@ pub struct DiskSample {
pub total_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub struct RemoteProcess {
pub pid: u32,
pub user: String,
pub cpu_percent: f32,
pub memory_bytes: u64,
pub command: String,
}
#[derive(Debug, Clone, Default)]
pub struct RemotePort {
pub protocol: String,
pub address: String,
pub port: u16,
pub state: String,
pub pid: Option<u32>,
pub process: String,
}
#[derive(Debug, Clone, Default)]
pub struct SystemSnapshot {
pub cpu_percent: f32,
@@ -52,8 +71,8 @@ impl SystemSampler {
sys.refresh_all();
let nets = Networks::new_with_refreshed_list();
let disks = Disks::new_with_refreshed_list();
let last_rx_total = nets.iter().map(|(_, d)| d.total_received()).sum();
let last_tx_total = nets.iter().map(|(_, d)| d.total_transmitted()).sum();
let last_rx_total = nets.values().map(|d| d.total_received()).sum();
let last_tx_total = nets.values().map(|d| d.total_transmitted()).sum();
Self {
sys,
@@ -81,8 +100,8 @@ impl SystemSampler {
let swap_total = self.sys.total_swap();
let swap_used = self.sys.used_swap();
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 rx_total: u64 = self.nets.values().map(|d| d.total_received()).sum();
let tx_total: u64 = self.nets.values().map(|d| d.total_transmitted()).sum();
let now = Instant::now();
let elapsed = now
.duration_since(self.last_instant)
@@ -104,24 +123,16 @@ impl SystemSampler {
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)
});
sort_disks(&mut disks);
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)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
mem_detail: format_usage(mem_used, mem_total),
swap_detail: format_usage(swap_used, swap_total),
net_rx: format_rate(rx_rate),
net_tx: format_rate(tx_rate),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
@@ -153,6 +164,22 @@ pub fn format_bytes(bytes: u64) -> String {
}
}
fn format_usage(used: u64, total: u64) -> String {
format!("{}/{}", format_bytes(used), format_bytes(total))
}
fn format_rate(bytes: u64) -> String {
format!("{}/s", format_bytes(bytes))
}
fn sort_disks(disks: &mut [DiskSample]) {
disks.sort_by(|a, b| {
(a.mount != "/")
.cmp(&(b.mount != "/"))
.then_with(|| a.mount.cmp(&b.mount))
});
}
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
let mut kv = BTreeMap::new();
let mut disks = Vec::new();
@@ -173,7 +200,7 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
.unwrap_or_default();
disks.push(DiskSample {
mount,
available_bytes,
available_bytes: available_bytes.min(total_bytes),
total_bytes,
});
continue;
@@ -189,13 +216,15 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
.get("CPU_PERCENT")
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.unwrap_or_default()
/ 100.0;
let mem_used = parse_u64(&kv, "MEM_USED");
let mem_total = parse_u64(&kv, "MEM_TOTAL");
let swap_used = parse_u64(&kv, "SWAP_USED");
let swap_total = parse_u64(&kv, "SWAP_TOTAL");
let mem_used = parse_u64(&kv, "MEM_USED").min(mem_total);
let swap_used = parse_u64(&kv, "SWAP_USED").min(swap_total);
let rx_rate = parse_u64(&kv, "NET_RX");
let tx_rate = parse_u64(&kv, "NET_TX");
@@ -203,24 +232,16 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
// (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)
});
sort_disks(&mut disks);
Ok(SystemSnapshot {
cpu_percent: cpu_percent.clamp(0.0, 1.0),
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)),
net_rx: format!("{}/s", format_bytes(rx_rate)),
net_tx: format!("{}/s", format_bytes(tx_rate)),
mem_detail: format_usage(mem_used, mem_total),
swap_detail: format_usage(swap_used, swap_total),
net_rx: format_rate(rx_rate),
net_tx: format_rate(tx_rate),
net_rx_rate: rx_rate,
net_tx_rate: tx_rate,
disks,
@@ -228,8 +249,168 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
})
}
pub fn remote_processes_from_ps(raw: &str) -> Vec<RemoteProcess> {
raw.lines()
.filter_map(|line| {
if let Some(record) = line.strip_prefix("PROCESS\t") {
let mut fields = record.splitn(5, '\t');
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?;
let command = fields.next()?.trim().to_string();
return (!command.is_empty()).then_some(RemoteProcess {
pid,
user,
cpu_percent,
memory_bytes,
command,
});
}
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse::<u32>().ok()?;
let user = fields.next()?.to_string();
let cpu_percent = fields
.next()?
.parse::<f32>()
.ok()
.filter(|value| value.is_finite())
.map(|value| value.max(0.0))?;
let memory_bytes = fields.next()?.parse::<u64>().ok()?.saturating_mul(1024);
let command = fields.collect::<Vec<_>>().join(" ");
if command.is_empty() {
return None;
}
Some(RemoteProcess {
pid,
user,
cpu_percent,
memory_bytes,
command,
})
})
.collect()
}
pub fn remote_ports_from_probe(raw: &str) -> Vec<RemotePort> {
raw.lines()
.filter_map(|line| {
let record = line.strip_prefix("PORT\t")?;
let mut fields = record.splitn(6, '\t');
let protocol = fields.next()?.trim().to_string();
let address = fields.next()?.trim().to_string();
let port = fields.next()?.parse::<u16>().ok()?;
let state = fields.next()?.trim().to_string();
let pid = match fields.next()?.trim() {
"" | "-" | "0" => None,
value => value.parse::<u32>().ok(),
};
let process = fields.next()?.trim().to_string();
if protocol.is_empty() || address.is_empty() || state.is_empty() {
return None;
}
Some(RemotePort {
protocol,
address,
port,
state,
pid,
process: if process.is_empty() {
"-".to_string()
} else {
process
},
})
})
.collect()
}
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
kv.get(key)
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{remote_ports_from_probe, remote_processes_from_ps, remote_snapshot_from_kv};
#[test]
fn parses_remote_process_rows_and_rss_bytes() {
let processes = remote_processes_from_ps(
" 42 alice 12.5 2048 worker --queue main\n 7 root 0.0 512 sshd\n",
);
assert_eq!(processes.len(), 2);
assert_eq!(processes[0].pid, 42);
assert_eq!(processes[0].user, "alice");
assert_eq!(processes[0].cpu_percent, 12.5);
assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024);
assert_eq!(processes[0].command, "worker --queue main");
}
#[test]
fn parses_structured_current_process_rows() {
let processes =
remote_processes_from_ps("PROCESS\t42\talice\t37.25\t2097152\tworker --queue main\n");
assert_eq!(processes.len(), 1);
assert_eq!(processes[0].pid, 42);
assert_eq!(processes[0].user, "alice");
assert_eq!(processes[0].cpu_percent, 37.25);
assert_eq!(processes[0].memory_bytes, 2 * 1024 * 1024);
assert_eq!(processes[0].command, "worker --queue main");
}
#[test]
fn skips_malformed_remote_process_rows() {
let processes = remote_processes_from_ps("PID USER CPU RSS COMMAND\n9 root bad 64 init\n");
assert!(processes.is_empty());
}
#[test]
fn clamps_remote_resource_values_to_valid_ranges() {
let snapshot = remote_snapshot_from_kv(
"CPU_PERCENT=250\nMEM_TOTAL=100\nMEM_USED=150\nSWAP_TOTAL=0\nSWAP_USED=10\nNET_RX=12\nNET_TX=34\nDISK=/data\t1048576\t2097152\nDISK=/\t3145728\t2097152\n",
)
.unwrap();
assert_eq!(snapshot.cpu_percent, 1.0);
assert_eq!(snapshot.mem_percent, 1.0);
assert_eq!(snapshot.swap_percent, 0.0);
assert_eq!(snapshot.disks[0].mount, "/");
assert_eq!(snapshot.disks[0].available_bytes, 2097152);
}
#[test]
fn parses_structured_remote_port_rows() {
let ports = remote_ports_from_probe(
"PORT\ttcp\t0.0.0.0\t22\tLISTEN\t123\tsshd\nPORT\tudp\t127.0.0.1\t5353\tUNCONN\t-\t-\n",
);
assert_eq!(ports.len(), 2);
assert_eq!(ports[0].protocol, "tcp");
assert_eq!(ports[0].address, "0.0.0.0");
assert_eq!(ports[0].port, 22);
assert_eq!(ports[0].state, "LISTEN");
assert_eq!(ports[0].pid, Some(123));
assert_eq!(ports[0].process, "sshd");
assert_eq!(ports[1].pid, None);
}
#[test]
fn skips_malformed_remote_port_rows() {
let ports = remote_ports_from_probe(
"PORT\ttcp\t0.0.0.0\tbad\tLISTEN\t1\tsshd\nPORT\ttcp\t\t80\tLISTEN\t1\tnginx\n",
);
assert!(ports.is_empty());
}
}
+231 -90
View File
@@ -12,6 +12,7 @@ use gpui_component::ActiveTheme as _;
use crate::Ashell;
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
use crate::terminal::highlight::HighlightStyle;
use crate::terminal::{RenderSnapshot, ViewportSelection};
#[derive(Clone, Copy)]
@@ -146,6 +147,7 @@ struct CursorLayout {
pub struct TerminalElement {
view: Entity<Ashell>,
focus_handle: FocusHandle,
pane_focused: bool,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: SharedString,
@@ -156,6 +158,20 @@ pub struct TerminalElement {
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub(crate) struct TerminalElementConfig {
pub(crate) view: Entity<Ashell>,
pub(crate) focus_handle: FocusHandle,
pub(crate) pane_focused: bool,
pub(crate) snapshot: RenderSnapshot,
pub(crate) marked_text: Option<String>,
pub(crate) font_family: SharedString,
pub(crate) font_size: Pixels,
pub(crate) line_height: Pixels,
pub(crate) cell_width: Pixels,
pub(crate) tab_id: String,
pub(crate) search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
}
pub struct PrepaintState {
bounds: Bounds<Pixels>,
metrics: TerminalMetrics,
@@ -284,21 +300,24 @@ impl InputHandler for TerminalInputHandler {
}
impl TerminalElement {
pub fn new(
view: Entity<Ashell>,
focus_handle: FocusHandle,
snapshot: RenderSnapshot,
marked_text: Option<String>,
font_family: SharedString,
font_size: Pixels,
line_height: Pixels,
cell_width: Pixels,
tab_id: String,
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
) -> Self {
pub(crate) fn new(config: TerminalElementConfig) -> Self {
let TerminalElementConfig {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
font_size,
line_height,
cell_width,
tab_id,
search_highlights,
} = config;
Self {
view,
focus_handle,
pane_focused,
snapshot,
marked_text,
font_family,
@@ -320,21 +339,16 @@ impl TerminalElement {
}
}
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun {
let mut fg = color_to_hsla(cell.fg, true, cx);
let mut bg = color_to_hsla(cell.bg, false, cx);
if cell.flags.contains(Flags::INVERSE) {
std::mem::swap(&mut fg, &mut bg);
}
if cell.flags.contains(Flags::DIM) {
fg.a *= 0.7;
}
fn cell_run_style(
&self,
cell: &alacritty_terminal::term::cell::Cell,
foreground: Hsla,
) -> TextRun {
let underline = cell
.flags
.intersects(Flags::ALL_UNDERLINES)
.then(|| UnderlineStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
wavy: cell.flags.contains(Flags::UNDERCURL),
});
@@ -342,7 +356,7 @@ impl TerminalElement {
.flags
.contains(Flags::STRIKEOUT)
.then(|| StrikethroughStyle {
color: Some(fg),
color: Some(foreground),
thickness: px(1.0),
});
@@ -359,7 +373,7 @@ impl TerminalElement {
TextRun {
len: cell.c.len_utf8(),
color: fg,
color: foreground,
background_color: None,
font: Font {
family: self.font_family.clone(),
@@ -375,7 +389,12 @@ impl TerminalElement {
fn layout_grid(
&self,
cx: &App,
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>, Vec<LayoutUnderline>) {
) -> (
Vec<LayoutRect>,
Vec<BatchedTextRun>,
Vec<LayoutCustomBlock>,
Vec<LayoutUnderline>,
) {
let view_read = self.view.read(cx);
let hovered_url = view_read.hovered_url.clone();
@@ -385,12 +404,6 @@ impl TerminalElement {
let mut underlines = 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(
@@ -402,24 +415,54 @@ impl TerminalElement {
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) || cell.flags.contains(Flags::INVERSE) {
let cell_position = (render_cell.row, render_cell.col);
let keyword_highlight = self.snapshot.highlights.get(&cell_position).copied();
let search_highlight = self
.search_highlights
.as_ref()
.and_then(|highlights| highlights.get(&cell_position))
.copied();
let (foreground, background) = resolve_cell_colors(
color_to_hsla(cell.fg, true, cx),
color_to_hsla(cell.bg, false, cx),
keyword_highlight,
cell.flags,
);
let cell_span = if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
};
if selected {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: if selected {
cx.theme().selection
} else if cell.flags.contains(Flags::INVERSE) {
color_to_hsla(cell.fg, true, cx)
} else {
bg
},
cells: cell_span,
color: cx.theme().selection,
});
} else {
let has_highlight_background =
keyword_highlight.is_some_and(|style| style.background.is_some());
if !is_default_bg(cell.bg)
|| cell.flags.contains(Flags::INVERSE)
|| has_highlight_background
{
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: background,
});
}
if let Some(search_background) = search_highlight {
rects.push(LayoutRect {
row: render_cell.row,
col: render_cell.col,
cells: cell_span,
color: search_background,
});
}
}
if is_blank(cell) {
@@ -429,22 +472,23 @@ impl TerminalElement {
continue;
}
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;
}
let style = self.cell_run_style(cell, foreground);
// Apply hover underline if mouse is hovering over this URL
if let Some(hu) = &hovered_url {
if hu.tab_id == self.tab_id
&& hu.cells.contains(&(render_cell.row as usize, render_cell.col as usize))
&& hu
.cells
.contains(&(render_cell.row as usize, render_cell.col as usize))
{
underlines.push(LayoutUnderline {
row: render_cell.row,
col: render_cell.col,
cells: if cell.flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 },
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
2
} else {
1
},
color: style.color,
});
}
@@ -510,12 +554,22 @@ impl TerminalElement {
)
}
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
fn cursor_layout(&self, window: &Window, cx: &App) -> Option<CursorLayout> {
if !should_render_cursor(
window.is_window_active(),
self.focus_handle.is_focused(window),
self.pane_focused,
) {
return None;
}
use crate::session::config::CursorStyle;
let cursor_style = self.view.read(cx).cursor_style;
let show_cursor = match cursor_style {
CursorStyle::Blink | CursorStyle::BeamBlink => {
if let Ok(duration) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
if let Ok(duration) =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
{
(duration.as_millis() / 600) % 2 == 0
} else {
true
@@ -544,6 +598,10 @@ impl TerminalElement {
}
}
fn should_render_cursor(window_active: bool, terminal_focused: bool, pane_focused: bool) -> bool {
window_active && terminal_focused && pane_focused
}
impl IntoElement for TerminalElement {
type Element = Self;
@@ -583,36 +641,30 @@ impl Element for TerminalElement {
_inspector_id: Option<&gpui::InspectorElementId>,
bounds: Bounds<Pixels>,
_request_layout: &mut Self::RequestLayoutState,
_window: &mut Window,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let _ = self.base_text_style(cx);
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
// Save the precise GPUI-rendered bounds of this terminal element.
// This is 100% accurate because it is recorded during layout prepaint.
// Local window animations emit many intermediate sizes. The view
// coalesces those requests before resizing its terminal grid and PTY.
let view = self.view.clone();
let tab_id = self.tab_id.clone();
let _ = view.update(cx, |this, cx| {
let old_bounds = this.terminal_bounds.insert(tab_id.clone(), bounds);
// Sync PTY size unconditionally on every prepaint layout pass to ensure
// absolute synchronization with GPUI layout regardless of intermediate events.
let line_height = this.terminal_line_height();
let cell_width = this.terminal_cell_width();
let w = bounds.size.width.as_f32();
let h = bounds.size.height.as_f32();
let cols = (w / cell_width).floor().max(1.0) as u16;
let rows = (h / line_height).floor().max(1.0) as u16;
if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == tab_id) {
tab.resize(cols, rows);
}
if old_bounds != Some(bounds) {
cx.notify();
}
let cols = (bounds.size.width.as_f32() / self.cell_width.as_f32())
.floor()
.max(1.0) as u16;
let rows = (bounds.size.height.as_f32() / self.line_height.as_f32())
.floor()
.max(1.0) as u16;
let resized_snapshot = view.update(cx, |this, cx| {
this.terminal_bounds.insert(tab_id.clone(), bounds);
this.resize_terminal_for_layout(&tab_id, cols, rows, cx)
});
if let Some(snapshot) = resized_snapshot {
self.snapshot = snapshot;
}
let (rects, runs, custom_blocks, underlines) = self.layout_grid(cx);
PrepaintState {
bounds,
@@ -623,7 +675,7 @@ impl Element for TerminalElement {
rects,
runs,
custom_blocks,
cursor: self.cursor_layout(cx),
cursor: self.cursor_layout(window, cx),
underlines,
}
}
@@ -640,9 +692,14 @@ impl Element for TerminalElement {
) {
// Compute a vertical offset to center the text grid vertically,
// distributing the leftover pixel remainder evenly to the top and bottom.
let grid_height = prepaint.metrics.line_height * (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32()).floor();
let grid_height = prepaint.metrics.line_height
* (prepaint.bounds.size.height.as_f32() / prepaint.metrics.line_height.as_f32())
.floor();
let y_offset = ((prepaint.bounds.size.height - grid_height) / 2.0).max(px(0.0));
let draw_origin = point(prepaint.bounds.origin.x, prepaint.bounds.origin.y + y_offset);
let draw_origin = point(
prepaint.bounds.origin.x,
prepaint.bounds.origin.y + y_offset,
);
for rect in &prepaint.rects {
rect.paint(draw_origin, prepaint.metrics, window);
@@ -657,10 +714,10 @@ impl Element for TerminalElement {
}
for block in &prepaint.custom_blocks {
let x = draw_origin.x.as_f32()
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y = draw_origin.y.as_f32()
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
let x =
draw_origin.x.as_f32() + block.col as f32 * prepaint.metrics.cell_width.as_f32();
let y =
draw_origin.y.as_f32() + block.row as f32 * prepaint.metrics.line_height.as_f32();
paint_custom_block(
window,
block.c,
@@ -801,10 +858,7 @@ fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline
for u in underlines {
if let Some(last) = merged.last_mut() {
if last.row == u.row
&& last.color == u.color
&& last.col + last.cells as i32 == u.col
{
if last.row == u.row && last.color == u.color && last.col + last.cells as i32 == u.col {
last.cells += u.cells;
continue;
}
@@ -832,6 +886,31 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
after_start && before_end
}
fn resolve_cell_colors(
mut foreground: Hsla,
mut background: Hsla,
highlight: Option<HighlightStyle>,
flags: Flags,
) -> (Hsla, Hsla) {
if let Some(highlight) = highlight {
if let Some(highlight_foreground) = highlight.foreground {
foreground = highlight_foreground;
}
if let Some(highlight_background) = highlight.background {
background = highlight_background;
}
}
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut foreground, &mut background);
}
if flags.contains(Flags::DIM) {
foreground.a *= 0.7;
}
(foreground, background)
}
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
cell.c == ' '
&& cell.zerowidth().is_none()
@@ -923,3 +1002,65 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
NamedColor::DimWhite => Hsla::from(rgb(0xe5e9f0)),
}
}
#[cfg(test)]
mod tests {
use super::{resolve_cell_colors, should_render_cursor};
use crate::terminal::highlight::HighlightStyle;
use alacritty_terminal::term::cell::Flags;
use gpui::Hsla;
fn color(h: f32, s: f32, l: f32) -> Hsla {
Hsla { h, s, l, a: 1.0 }
}
#[test]
fn renders_cursor_only_for_focused_pane_in_focused_terminal_window() {
assert!(should_render_cursor(true, true, true));
assert!(!should_render_cursor(false, true, true));
assert!(!should_render_cursor(true, false, true));
assert!(!should_render_cursor(true, true, false));
}
#[test]
fn applies_highlight_channels_before_inverse_terminal_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let highlight_background = color(0.6, 0.8, 0.4);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: Some(highlight_background),
}),
Flags::INVERSE,
);
assert_eq!(foreground, highlight_background);
assert_eq!(background, highlight_foreground);
}
#[test]
fn foreground_highlight_preserves_background_and_dim_style() {
let original_foreground = color(0.0, 0.0, 0.8);
let original_background = color(0.0, 0.0, 0.1);
let highlight_foreground = color(0.1, 0.8, 0.5);
let (foreground, background) = resolve_cell_colors(
original_foreground,
original_background,
Some(HighlightStyle {
foreground: Some(highlight_foreground),
background: None,
}),
Flags::DIM,
);
assert_eq!(foreground.alpha(1.0), highlight_foreground);
assert!((foreground.a - 0.7).abs() < f32::EPSILON);
assert_eq!(background, original_background);
}
}
+767 -221
View File
File diff suppressed because it is too large Load Diff
+1079 -35
View File
File diff suppressed because it is too large Load Diff
+2024 -46
View File
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
use std::borrow::Cow;
use encoding_rs::{
BIG5, CoderResult, EUC_KR, Encoding, GB18030, GBK, SHIFT_JIS, UTF_8, UTF_16BE, UTF_16LE,
WINDOWS_1252,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TextEncoding {
#[default]
Utf8,
Gb18030,
Gbk,
Big5,
ShiftJis,
EucKr,
Windows1252,
Utf16Le,
Utf16Be,
}
pub(crate) const TERMINAL_ENCODINGS: &[TextEncoding] = &[
TextEncoding::Utf8,
TextEncoding::Gb18030,
TextEncoding::Gbk,
TextEncoding::Big5,
TextEncoding::ShiftJis,
TextEncoding::EucKr,
TextEncoding::Windows1252,
];
pub(crate) const FILE_ENCODINGS: &[TextEncoding] = &[
TextEncoding::Utf8,
TextEncoding::Utf16Le,
TextEncoding::Utf16Be,
TextEncoding::Gb18030,
TextEncoding::Gbk,
TextEncoding::Big5,
TextEncoding::ShiftJis,
TextEncoding::EucKr,
TextEncoding::Windows1252,
];
impl TextEncoding {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Utf8 => "UTF-8",
Self::Gb18030 => "GB18030",
Self::Gbk => "GBK",
Self::Big5 => "Big5",
Self::ShiftJis => "Shift_JIS",
Self::EucKr => "EUC-KR",
Self::Windows1252 => "Windows-1252",
Self::Utf16Le => "UTF-16 LE",
Self::Utf16Be => "UTF-16 BE",
}
}
pub(crate) fn encoding(self) -> &'static Encoding {
match self {
Self::Utf8 => UTF_8,
Self::Gb18030 => GB18030,
Self::Gbk => GBK,
Self::Big5 => BIG5,
Self::ShiftJis => SHIFT_JIS,
Self::EucKr => EUC_KR,
Self::Windows1252 => WINDOWS_1252,
Self::Utf16Le => UTF_16LE,
Self::Utf16Be => UTF_16BE,
}
}
pub(crate) fn detect_bom(bytes: &[u8]) -> Option<Self> {
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
Some(Self::Utf8)
} else if bytes.starts_with(&[0xFF, 0xFE]) {
Some(Self::Utf16Le)
} else if bytes.starts_with(&[0xFE, 0xFF]) {
Some(Self::Utf16Be)
} else {
None
}
}
pub(crate) fn decode_file(self, bytes: &[u8]) -> (String, bool, bool) {
let bom_len = self.matching_bom_len(bytes);
let (text, had_errors) = self
.encoding()
.decode_without_bom_handling(&bytes[bom_len..]);
(text.into_owned(), had_errors, bom_len > 0)
}
pub(crate) fn encode_file(self, text: &str, with_bom: bool) -> (Vec<u8>, bool) {
if matches!(self, Self::Utf16Le | Self::Utf16Be) {
let mut bytes = Vec::with_capacity(text.len().saturating_mul(2).saturating_add(2));
if with_bom {
bytes.extend_from_slice(if self == Self::Utf16Le {
&[0xFF, 0xFE]
} else {
&[0xFE, 0xFF]
});
}
for code_unit in text.encode_utf16() {
let encoded = if self == Self::Utf16Le {
code_unit.to_le_bytes()
} else {
code_unit.to_be_bytes()
};
bytes.extend_from_slice(&encoded);
}
return (bytes, false);
}
let (encoded, _, had_errors) = self.encoding().encode(text);
let mut bytes = Vec::with_capacity(encoded.len() + 3);
if with_bom {
match self {
Self::Utf8 => bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]),
Self::Utf16Le => bytes.extend_from_slice(&[0xFF, 0xFE]),
Self::Utf16Be => bytes.extend_from_slice(&[0xFE, 0xFF]),
_ => {}
}
}
bytes.extend_from_slice(&encoded);
(bytes, had_errors)
}
pub(crate) fn default_bom(self) -> bool {
matches!(self, Self::Utf16Le | Self::Utf16Be)
}
pub(crate) fn encode_terminal_input<'a>(self, bytes: &'a [u8]) -> Cow<'a, [u8]> {
if self == Self::Utf8 {
return Cow::Borrowed(bytes);
}
let Ok(text) = std::str::from_utf8(bytes) else {
return Cow::Borrowed(bytes);
};
let (encoded, _, _) = self.encoding().encode(text);
encoded
}
fn matching_bom_len(self, bytes: &[u8]) -> usize {
match self {
Self::Utf8 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => 3,
Self::Utf16Le if bytes.starts_with(&[0xFF, 0xFE]) => 2,
Self::Utf16Be if bytes.starts_with(&[0xFE, 0xFF]) => 2,
_ => 0,
}
}
}
pub(crate) struct StreamingDecoder {
decoder: encoding_rs::Decoder,
}
impl StreamingDecoder {
pub(crate) fn new(encoding: TextEncoding) -> Self {
Self {
decoder: encoding.encoding().new_decoder_without_bom_handling(),
}
}
pub(crate) fn decode(&mut self, bytes: &[u8]) -> Vec<u8> {
let initial_capacity = self
.decoder
.max_utf8_buffer_length(bytes.len())
.unwrap_or_else(|| bytes.len().saturating_mul(4).saturating_add(16));
let mut output = String::with_capacity(initial_capacity);
let mut remaining = bytes;
loop {
let (result, read, _) = self.decoder.decode_to_string(remaining, &mut output, false);
remaining = &remaining[read..];
match result {
CoderResult::InputEmpty => break,
CoderResult::OutputFull => {
let additional = self
.decoder
.max_utf8_buffer_length(remaining.len())
.unwrap_or_else(|| remaining.len().saturating_mul(4).saturating_add(16))
.max(16);
output.reserve(additional);
}
}
}
output.into_bytes()
}
}
#[cfg(test)]
mod tests {
use super::{StreamingDecoder, TextEncoding};
#[test]
fn streaming_decoder_preserves_split_multibyte_characters() {
let (encoded, _) = TextEncoding::Gbk.encode_file("中文", false);
let mut decoder = StreamingDecoder::new(TextEncoding::Gbk);
let mut decoded = decoder.decode(&encoded[..1]);
decoded.extend(decoder.decode(&encoded[1..]));
assert_eq!(String::from_utf8(decoded).unwrap(), "中文");
}
#[test]
fn file_encoding_preserves_matching_bom() {
let original = [0xFF, 0xFE, b'A', 0x00];
let (text, had_errors, has_bom) = TextEncoding::Utf16Le.decode_file(&original);
let (encoded, encode_errors) = TextEncoding::Utf16Le.encode_file(&text, has_bom);
assert!(!had_errors);
assert!(!encode_errors);
assert_eq!(encoded, original);
}
}