mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
65
Commits
v0.4.7-fix1
...
v0.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4079a60f | ||
|
|
9d0868dd7c | ||
|
|
f63b8b418c | ||
|
|
e1d39d8dc4 | ||
|
|
c9f722071a | ||
|
|
2e279e206f | ||
|
|
7b410afea7 | ||
|
|
b76f9c7375 | ||
|
|
28fc1a6b50 | ||
|
|
52741e3c32 | ||
|
|
3ef6e8cfb8 | ||
|
|
6ccb9ff601 | ||
|
|
168a20e157 | ||
|
|
ee98a933e6 | ||
|
|
b4551ee1cc | ||
|
|
233cde12e8 | ||
|
|
b1efaa1d8f | ||
|
|
4607b2c995 | ||
|
|
f6816da18b | ||
|
|
350dcbc5b4 | ||
|
|
44348d4d75 | ||
|
|
9163d3a970 | ||
|
|
d85c1f4f55 | ||
|
|
61e7fb7b0d | ||
|
|
1bb922f955 | ||
|
|
a06d1c668d | ||
|
|
3b9ee02cce | ||
|
|
e7ca7bdc23 | ||
|
|
84eccb5521 | ||
|
|
4da9949c84 | ||
|
|
85fb354f80 | ||
|
|
584f962298 | ||
|
|
a32fd9437d | ||
|
|
4c292c060c | ||
|
|
c0a954ea0c | ||
|
|
ab41ccbd6b | ||
|
|
43717e4378 | ||
|
|
31218f7a37 | ||
|
|
af5c2831f6 | ||
|
|
a8669738e6 | ||
|
|
2dad1fa5a1 | ||
|
|
e2bf76f7b2 | ||
|
|
a654695d7b | ||
|
|
cb078ef572 | ||
|
|
c0aa21a994 | ||
|
|
c65354e8d6 | ||
|
|
fcfbefe3ab | ||
|
|
39d369aaaf | ||
|
|
5c0c0cc701 | ||
|
|
99b7fe30e4 | ||
|
|
24f817f9e0 | ||
|
|
1a3f638004 | ||
|
|
5f318f66c8 | ||
|
|
8854ee9afb | ||
|
|
8cc64acdc1 | ||
|
|
77a0380ba7 | ||
|
|
3770826764 | ||
|
|
cae6323888 | ||
|
|
54bcc97fe1 | ||
|
|
273bc2fe0c | ||
|
|
f0e02e0ce7 | ||
|
|
f6d9cd00e7 | ||
|
|
bfc7c9ac83 | ||
|
|
fd19daedad | ||
|
|
042834c7d5 |
@@ -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
@@ -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
@@ -1,2 +1,3 @@
|
||||
/target/
|
||||
dist/
|
||||
target/
|
||||
.DS_Store
|
||||
|
||||
@@ -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
+322
-33
@@ -315,7 +315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ashell"
|
||||
version = "0.4.7"
|
||||
version = "0.5.1"
|
||||
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,19 +356,25 @@ 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",
|
||||
"tokio-socks",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"windows 0.57.0",
|
||||
"winreg 0.52.0",
|
||||
"winres",
|
||||
"zip",
|
||||
]
|
||||
@@ -874,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"
|
||||
@@ -1587,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"
|
||||
@@ -2759,7 +2794,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"log",
|
||||
"lyon",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"media",
|
||||
"metal",
|
||||
"num_cpus",
|
||||
@@ -2949,7 +2984,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"media",
|
||||
"metal",
|
||||
"objc",
|
||||
@@ -3682,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"
|
||||
@@ -3951,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"
|
||||
@@ -4098,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"
|
||||
@@ -4396,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"
|
||||
@@ -4501,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"
|
||||
@@ -4728,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",
|
||||
]
|
||||
@@ -4742,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",
|
||||
]
|
||||
@@ -4758,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"
|
||||
@@ -4769,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"
|
||||
@@ -4781,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"
|
||||
@@ -4804,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",
|
||||
@@ -5507,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"
|
||||
@@ -5597,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"
|
||||
@@ -6896,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"
|
||||
@@ -7219,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",
|
||||
]
|
||||
@@ -7559,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"
|
||||
@@ -8132,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"
|
||||
@@ -8309,7 +8535,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"log",
|
||||
"mach2",
|
||||
"mach2 0.5.0",
|
||||
"nix 0.29.0",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
@@ -8944,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"
|
||||
@@ -9020,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"
|
||||
@@ -9093,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"
|
||||
@@ -9126,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"
|
||||
@@ -9397,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"
|
||||
@@ -9562,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"
|
||||
|
||||
+48
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.4.7"
|
||||
version = "0.5.1"
|
||||
edition = "2024"
|
||||
authors = ["ashell contributors"]
|
||||
description = "A GPUI Component based SSH and local terminal client"
|
||||
@@ -9,10 +9,13 @@ rust-version = "1.85.0"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
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"] }
|
||||
@@ -31,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"
|
||||
@@ -49,10 +53,48 @@ 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"]
|
||||
extended-description = "A GPUI Component based SSH and local terminal client"
|
||||
section = "terminal"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["target/release/ashell", "usr/bin/", "755"],
|
||||
["assets/ashell.desktop", "usr/share/applications/", "644"],
|
||||
["assets/icons/ashell.png", "usr/share/icons/hicolor/256x256/apps/", "644"],
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
@@ -61,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" }
|
||||
|
||||
+80
-3
@@ -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
|
||||
@@ -82,6 +126,39 @@ open target/release/ashell.app
|
||||
|
||||
The packaging script creates a standard `.app` bundle. It does not attach an entitlements file, and after signing, it verifies that `com.apple.security.app-sandbox` is not present (meaning it runs non-sandboxed).
|
||||
|
||||
## Package Linux (Debian/Ubuntu)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
sudo apt install pkg-config libfontconfig1-dev
|
||||
cargo install cargo-deb
|
||||
```
|
||||
|
||||
### Build .deb
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo deb
|
||||
```
|
||||
|
||||
The `.deb` file will be generated at:
|
||||
|
||||
```
|
||||
target/debian/ashell_0.4.8-1_amd64.deb
|
||||
```
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
sudo dpkg -i target/debian/ashell_0.4.8-1_amd64.deb
|
||||
```
|
||||
|
||||
After installation, you can launch from the application menu or by running `ashell` in the terminal. The `.deb` package includes:
|
||||
- `/usr/bin/ashell` — Main binary
|
||||
- `/usr/share/applications/ashell.desktop` — Desktop entry
|
||||
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — Application icon
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
|
||||
|
||||
@@ -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
|
||||
@@ -82,6 +130,39 @@ open target/release/ashell.app
|
||||
|
||||
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
|
||||
|
||||
## 打包 Linux (Debian/Ubuntu)
|
||||
|
||||
### 前置条件
|
||||
|
||||
```bash
|
||||
sudo apt install pkg-config libfontconfig1-dev
|
||||
cargo install cargo-deb
|
||||
```
|
||||
|
||||
### 构建 .deb 包
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo deb
|
||||
```
|
||||
|
||||
生成的 `.deb` 文件位于:
|
||||
|
||||
```
|
||||
target/debian/ashell_0.4.9-1_amd64.deb
|
||||
```
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
sudo dpkg -i target/debian/ashell_0.4.9-1_amd64.deb
|
||||
```
|
||||
|
||||
安装后可通过应用菜单或命令行 `ashell` 启动。`.deb` 包包含以下内容:
|
||||
- `/usr/bin/ashell` — 主程序
|
||||
- `/usr/share/applications/ashell.desktop` — 桌面入口
|
||||
- `/usr/share/icons/hicolor/256x256/apps/ashell.png` — 应用图标
|
||||
|
||||
## 协议
|
||||
|
||||
本项目采用 [GPL-3.0-or-later 协议](LICENSE) 开源。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Name=Ashell
|
||||
Comment=A GPUI Component based SSH and local terminal client
|
||||
Exec=ashell
|
||||
Icon=ashell
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=System;TerminalEmulator;Utility;
|
||||
StartupWMClass=ashell
|
||||
+213
-25
@@ -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,35 +89,105 @@ 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"
|
||||
proxy: "Proxy"
|
||||
proxy_none: "Follow Global"
|
||||
proxy_host: "Proxy Host"
|
||||
proxy_port: "Proxy Port"
|
||||
proxy_user: "Proxy Username (optional)"
|
||||
proxy_password: "Proxy Password (optional)"
|
||||
ui_font_size: "UI Font Size"
|
||||
terminal_font_size: "Terminal Font Size"
|
||||
system_default: "System Default"
|
||||
ui_font_family: "UI Font"
|
||||
terminal_font_family: "Terminal Font"
|
||||
cursor_style: "Cursor Style"
|
||||
cursor_style_default: "Default"
|
||||
cursor_style_blink: "Blink"
|
||||
cursor_style_beam: "Line"
|
||||
cursor_style_beam_blink: "Line Blink"
|
||||
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}..."
|
||||
@@ -101,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"
|
||||
@@ -112,42 +224,105 @@ software_builtin: "Built-in"
|
||||
retry: "Retry"
|
||||
right_click_copy_paste: "Right-click Copy/Paste"
|
||||
keyword_highlight: "Terminal Keyword Highlighting"
|
||||
copy_paste_hint: "Regardless of this switch, you can always use %{key} + C/V to copy/paste."
|
||||
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"
|
||||
@@ -157,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"
|
||||
@@ -183,4 +358,17 @@ sync_downloading: "Downloading and decrypting configuration..."
|
||||
sync_upload_complete: "Configuration uploaded"
|
||||
sync_download_complete: "Configuration downloaded"
|
||||
sync_failed: "Synchronization failed"
|
||||
keyboard_interactive: "Keyboard Interactive"
|
||||
settings_proxy: "Proxy"
|
||||
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_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"
|
||||
|
||||
+215
-29
@@ -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,35 +89,105 @@ 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: "设置"
|
||||
proxy: "代理"
|
||||
proxy_none: "跟随全局"
|
||||
proxy_host: "代理服务器地址"
|
||||
proxy_port: "端口"
|
||||
proxy_user: "用户名 (可选)"
|
||||
proxy_password: "密码 (可选)"
|
||||
ui_font_size: "界面字体大小"
|
||||
terminal_font_size: "终端字体大小"
|
||||
system_default: "系统默认"
|
||||
ui_font_family: "界面字体"
|
||||
terminal_font_family: "终端字体"
|
||||
monitoring_position: "系统监控显示位置"
|
||||
cursor_style: "光标样式"
|
||||
cursor_style_default: "默认"
|
||||
cursor_style_blink: "闪烁"
|
||||
cursor_style_beam: "细线"
|
||||
cursor_style_beam_blink: "细线闪烁"
|
||||
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}..."
|
||||
@@ -102,8 +213,8 @@ no_transfers_yet: "暂无传输记录"
|
||||
downloading: "正在下载"
|
||||
paused: "已暂停"
|
||||
completed: "已完成"
|
||||
terminal_attention_required: "终端中有需要关注的新状态"
|
||||
failed: "失败"
|
||||
cancelled: "已取消"
|
||||
interrupted: "已中断"
|
||||
session: "会话"
|
||||
zombie: "僵尸任务"
|
||||
@@ -113,43 +224,105 @@ software_builtin: "软件内置"
|
||||
retry: "重试"
|
||||
right_click_copy_paste: "右键复制/粘贴"
|
||||
keyword_highlight: "终端关键词高亮"
|
||||
copy_paste_hint: "不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
|
||||
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: "向上切换焦点"
|
||||
@@ -159,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: "融合"
|
||||
@@ -185,4 +358,17 @@ sync_downloading: "正在下载并解密配置..."
|
||||
sync_upload_complete: "配置上传完成"
|
||||
sync_download_complete: "配置下载完成"
|
||||
sync_failed: "同步失败"
|
||||
keyboard_interactive: "键盘交互"
|
||||
settings_proxy: "代理设置"
|
||||
enable_proxy: "启用代理功能"
|
||||
read_env_proxy: "启动时读取环境变量"
|
||||
read_env_proxy_desc: "若开启此项,启动时将读取环境变量,下方配置的全局代理设置将失效。"
|
||||
global_proxy_settings: "全局代理配置"
|
||||
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: "导入配置"
|
||||
|
||||
@@ -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您确定要继续吗?
|
||||
@@ -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
|
||||
@@ -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"] }
|
||||
@@ -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,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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +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;
|
||||
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
|
||||
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
|
||||
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
|
||||
pub(crate) const SIDEBAR_WIDTH: f32 = 310.0;
|
||||
pub(crate) const TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0;
|
||||
|
||||
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+3266
-653
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
@@ -29,7 +30,9 @@ gpui::actions!(
|
||||
SplitPaneRight,
|
||||
SplitPaneUp,
|
||||
SplitPaneDown,
|
||||
ClosePane
|
||||
ClosePane,
|
||||
Copy,
|
||||
Paste
|
||||
]
|
||||
);
|
||||
|
||||
@@ -60,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 {
|
||||
@@ -68,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",
|
||||
@@ -123,6 +131,24 @@ pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
|
||||
label_key: "settings_close_pane",
|
||||
default_suffix: "w",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "Copy",
|
||||
label_key: "settings_copy",
|
||||
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"
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn default_modifier() -> &'static str {
|
||||
@@ -134,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)
|
||||
@@ -186,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();
|
||||
|
||||
@@ -198,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()),
|
||||
@@ -224,6 +288,8 @@ pub(crate) fn unbind_all_workspace_keys(cx: &mut App, config: &ConfigStore) {
|
||||
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
|
||||
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
|
||||
unbind_action!("ClosePane", crate::ClosePane);
|
||||
unbind_action!("Copy", crate::Copy);
|
||||
unbind_action!("Paste", crate::Paste);
|
||||
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
@@ -240,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()));
|
||||
}
|
||||
}
|
||||
@@ -260,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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,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);
|
||||
@@ -280,6 +349,8 @@ fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
|
||||
bind_action!("SplitPaneUp", crate::SplitPaneUp);
|
||||
bind_action!("SplitPaneDown", crate::SplitPaneDown);
|
||||
bind_action!("ClosePane", crate::ClosePane);
|
||||
bind_action!("Copy", crate::Copy);
|
||||
bind_action!("Paste", crate::Paste);
|
||||
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
@@ -289,7 +360,16 @@ impl KeybindingsPage {
|
||||
let groups = [
|
||||
(
|
||||
"settings_group_keybind_general",
|
||||
vec!["OpenSettings", "OpenSession", "OpenTransfers", "NewSsh", "OpenSearch"],
|
||||
vec![
|
||||
"OpenSettings",
|
||||
"OpenSession",
|
||||
"OpenTransfers",
|
||||
"NewSsh",
|
||||
"OpenSearch",
|
||||
"QuitApplication",
|
||||
"Copy",
|
||||
"Paste",
|
||||
],
|
||||
),
|
||||
(
|
||||
"settings_group_keybind_zoom",
|
||||
@@ -361,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())
|
||||
@@ -373,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();
|
||||
|
||||
+1390
-204
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
Along, App, Axis, Bounds, Context, ElementId, EventEmitter, IsZero, Pixels, Window, px,
|
||||
};
|
||||
|
||||
mod panel;
|
||||
mod resize_handle;
|
||||
pub use panel::*;
|
||||
pub(crate) use resize_handle::*;
|
||||
|
||||
pub(crate) const PANEL_MIN_SIZE: Pixels = px(100.);
|
||||
|
||||
pub enum ResizablePanelEvent {
|
||||
Resized,
|
||||
}
|
||||
|
||||
/// Create a [`ResizablePanelGroup`] with horizontal resizing
|
||||
pub fn h_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
|
||||
ResizablePanelGroup::new(id).axis(Axis::Horizontal)
|
||||
}
|
||||
|
||||
/// Create a [`ResizablePanelGroup`] with vertical resizing
|
||||
pub fn v_resizable(id: impl Into<ElementId>) -> ResizablePanelGroup {
|
||||
ResizablePanelGroup::new(id).axis(Axis::Vertical)
|
||||
}
|
||||
|
||||
/// Create a [`ResizablePanel`].
|
||||
pub fn resizable_panel() -> ResizablePanel {
|
||||
ResizablePanel::new()
|
||||
}
|
||||
|
||||
/// State for a [`ResizablePanel`]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResizableState {
|
||||
/// The `axis` will sync to actual axis of the ResizablePanelGroup in use.
|
||||
axis: Axis,
|
||||
pub(crate) panels: Vec<ResizablePanelState>,
|
||||
pub(crate) sizes: Vec<Pixels>,
|
||||
pub(crate) resizing_panel_ix: Option<usize>,
|
||||
pub(crate) bounds: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
impl Default for ResizableState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
axis: Axis::Horizontal,
|
||||
panels: vec![],
|
||||
sizes: vec![],
|
||||
resizing_panel_ix: None,
|
||||
bounds: Bounds::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResizableState {
|
||||
/// Get the size of the panels.
|
||||
pub fn sizes(&self) -> &Vec<Pixels> {
|
||||
&self.sizes
|
||||
}
|
||||
|
||||
/// Programmatically resize the panel at `ix` to `size`, redistributing
|
||||
/// space among siblings using the same logic as a drag.
|
||||
///
|
||||
/// Sizes are clamped to the panel's `size_range` and to the container.
|
||||
/// Emits `ResizablePanelEvent::Resized` so subscribers (e.g. preference
|
||||
/// persistence) see the change just as if the user had dragged a handle.
|
||||
///
|
||||
/// Out-of-range indices are a no-op. For the last panel, space is taken
|
||||
/// from the previous sibling (the last panel has no handle of its own).
|
||||
pub fn resize_panel(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
size: Pixels,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if ix >= self.sizes.len() {
|
||||
return;
|
||||
}
|
||||
if ix + 1 < self.sizes.len() {
|
||||
self.resize_panel_at_handle(ix, size, window, cx);
|
||||
} else if ix > 0 {
|
||||
// Last panel: drive its size by resizing the previous sibling so
|
||||
// the freed space lands here.
|
||||
let delta = self.sizes[ix] - size;
|
||||
let prev = self.sizes[ix - 1];
|
||||
self.resize_panel_at_handle(ix - 1, prev + delta, window, cx);
|
||||
}
|
||||
self.done_resizing(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn insert_panel(
|
||||
&mut self,
|
||||
size: Option<Pixels>,
|
||||
ix: Option<usize>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let panel_state = ResizablePanelState {
|
||||
size,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let size = size.unwrap_or(PANEL_MIN_SIZE);
|
||||
|
||||
// We make sure that the size always sums up to the container size
|
||||
// by reducing the size of all other panels first.
|
||||
let container_size = self.container_size().max(px(1.));
|
||||
let total_leftover_size = (container_size - size).max(px(1.));
|
||||
|
||||
for (i, panel) in self.panels.iter_mut().enumerate() {
|
||||
let ratio = self.sizes[i] / container_size;
|
||||
self.sizes[i] = total_leftover_size * ratio;
|
||||
panel.size = Some(self.sizes[i]);
|
||||
}
|
||||
|
||||
if let Some(ix) = ix {
|
||||
self.panels.insert(ix, panel_state);
|
||||
self.sizes.insert(ix, size);
|
||||
} else {
|
||||
self.panels.push(panel_state);
|
||||
self.sizes.push(size);
|
||||
};
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn sync_panels_count(
|
||||
&mut self,
|
||||
axis: Axis,
|
||||
panels_count: usize,
|
||||
fit_to_container: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let mut changed = self.axis != axis;
|
||||
self.axis = axis;
|
||||
|
||||
if panels_count > self.panels.len() {
|
||||
let diff = panels_count - self.panels.len();
|
||||
self.panels
|
||||
.extend(vec![ResizablePanelState::default(); diff]);
|
||||
self.sizes.extend(vec![PANEL_MIN_SIZE; diff]);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if panels_count < self.panels.len() {
|
||||
self.panels.truncate(panels_count);
|
||||
self.sizes.truncate(panels_count);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn update_panel_size(
|
||||
&mut self,
|
||||
panel_ix: usize,
|
||||
bounds: Bounds<Pixels>,
|
||||
size_range: Range<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let size = bounds.size.along(self.axis);
|
||||
// This check is only necessary to stop the very first panel from resizing on its own
|
||||
// it needs to be passed when the panel is freshly created so we get the initial size,
|
||||
// but its also fine when it sometimes passes later.
|
||||
if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() {
|
||||
self.sizes[panel_ix] = size;
|
||||
self.panels[panel_ix].size = Some(size);
|
||||
}
|
||||
self.panels[panel_ix].bounds = bounds;
|
||||
self.panels[panel_ix].size_range = size_range;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context<Self>) {
|
||||
self.panels.remove(panel_ix);
|
||||
self.sizes.remove(panel_ix);
|
||||
if let Some(resizing_panel_ix) = self.resizing_panel_ix {
|
||||
if resizing_panel_ix > panel_ix {
|
||||
self.resizing_panel_ix = Some(resizing_panel_ix - 1);
|
||||
}
|
||||
}
|
||||
self.adjust_to_container_size(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn replace_panel(
|
||||
&mut self,
|
||||
panel_ix: usize,
|
||||
panel: ResizablePanelState,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let old_size = self.sizes[panel_ix];
|
||||
|
||||
self.panels[panel_ix] = panel;
|
||||
self.sizes[panel_ix] = old_size;
|
||||
self.adjust_to_container_size(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.panels.clear();
|
||||
self.sizes.clear();
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn container_size(&self) -> Pixels {
|
||||
self.bounds.size.along(self.axis)
|
||||
}
|
||||
|
||||
pub(crate) fn done_resizing(&mut self, cx: &mut Context<Self>) {
|
||||
self.resizing_panel_ix = None;
|
||||
cx.emit(ResizablePanelEvent::Resized);
|
||||
}
|
||||
|
||||
fn panel_size_range(&self, ix: usize) -> Range<Pixels> {
|
||||
let Some(panel) = self.panels.get(ix) else {
|
||||
return PANEL_MIN_SIZE..Pixels::MAX;
|
||||
};
|
||||
|
||||
panel.size_range.clone()
|
||||
}
|
||||
|
||||
fn sync_real_panel_sizes(&mut self, _: &App) {
|
||||
for (i, panel) in self.panels.iter().enumerate() {
|
||||
self.sizes[i] = panel.bounds.size.along(self.axis);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resize_panel_at_handle(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
size: Pixels,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let old_sizes = self.sizes.clone();
|
||||
|
||||
let mut ix = ix;
|
||||
if ix >= old_sizes.len() - 1 {
|
||||
return;
|
||||
}
|
||||
let container_size = self.container_size();
|
||||
self.sync_real_panel_sizes(cx);
|
||||
|
||||
let move_changed = size - old_sizes[ix];
|
||||
if move_changed == px(0.) {
|
||||
return;
|
||||
}
|
||||
|
||||
let size_range = self.panel_size_range(ix);
|
||||
let new_size = size.clamp(size_range.start, size_range.end);
|
||||
let is_expand = move_changed > px(0.);
|
||||
|
||||
let main_ix = ix;
|
||||
let mut new_sizes = old_sizes.clone();
|
||||
|
||||
if is_expand {
|
||||
let mut changed = new_size - old_sizes[ix];
|
||||
new_sizes[ix] = new_size;
|
||||
|
||||
while changed > px(0.) && ix < old_sizes.len() - 1 {
|
||||
ix += 1;
|
||||
let size_range = self.panel_size_range(ix);
|
||||
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
|
||||
let to_reduce = changed.min(available_size);
|
||||
new_sizes[ix] -= to_reduce;
|
||||
changed -= to_reduce;
|
||||
}
|
||||
} else {
|
||||
let mut changed = new_size - size;
|
||||
new_sizes[ix] = new_size;
|
||||
|
||||
while changed > px(0.) && ix > 0 {
|
||||
ix -= 1;
|
||||
let size_range = self.panel_size_range(ix);
|
||||
let available_size = (new_sizes[ix] - size_range.start).max(px(0.));
|
||||
let to_reduce = changed.min(available_size);
|
||||
changed -= to_reduce;
|
||||
new_sizes[ix] -= to_reduce;
|
||||
}
|
||||
|
||||
new_sizes[main_ix + 1] += old_sizes[main_ix] - size - changed;
|
||||
}
|
||||
|
||||
let total_size: Pixels = new_sizes.iter().map(|s| s.as_f32()).sum::<f32>().into();
|
||||
if total_size > container_size {
|
||||
let overflow = total_size - container_size;
|
||||
new_sizes[main_ix] = (new_sizes[main_ix] - overflow).max(size_range.start);
|
||||
}
|
||||
|
||||
for (i, _) in old_sizes.iter().enumerate() {
|
||||
let size = new_sizes[i];
|
||||
self.panels[i].size = Some(size);
|
||||
}
|
||||
self.sizes = new_sizes;
|
||||
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;
|
||||
}
|
||||
|
||||
let container_size = self.container_size();
|
||||
let total_size = px(self.sizes.iter().map(|s| s.as_f32()).sum::<f32>());
|
||||
|
||||
for i in 0..self.panels.len() {
|
||||
let size = self.sizes[i];
|
||||
let ratio = size / total_size;
|
||||
let new_size = container_size * ratio;
|
||||
|
||||
self.sizes[i] = new_size;
|
||||
self.panels[i].size = Some(new_size);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ResizablePanelEvent> for ResizableState {}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct ResizablePanelState {
|
||||
pub size: Option<Pixels>,
|
||||
pub size_range: Range<Pixels>,
|
||||
pub(crate) bounds: Bounds<Pixels>,
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
use std::{
|
||||
ops::{Deref, Range},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
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, StyledExt, h_flex, v_flex};
|
||||
|
||||
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;
|
||||
impl Render for DragPanel {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement {
|
||||
Empty
|
||||
}
|
||||
}
|
||||
|
||||
/// A group of resizable panels.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ResizablePanelGroup {
|
||||
id: ElementId,
|
||||
state: Option<Entity<ResizableState>>,
|
||||
axis: Axis,
|
||||
size: Option<Pixels>,
|
||||
children: Vec<ResizablePanel>,
|
||||
on_resize: ResizeCallback,
|
||||
locked: bool,
|
||||
independent_resize: bool,
|
||||
}
|
||||
|
||||
impl ResizablePanelGroup {
|
||||
/// Create a new resizable panel group.
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
axis: Axis::Horizontal,
|
||||
children: vec![],
|
||||
state: None,
|
||||
size: None,
|
||||
on_resize: Rc::new(|_, _, _| {}),
|
||||
locked: false,
|
||||
independent_resize: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind yourself to a resizable state entity.
|
||||
///
|
||||
/// If not provided, it will handle its own state internally.
|
||||
pub fn with_state(mut self, state: &Entity<ResizableState>) -> Self {
|
||||
self.state = Some(state.clone());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the axis of the resizable panel group, default is horizontal.
|
||||
pub fn axis(mut self, axis: Axis) -> Self {
|
||||
self.axis = axis;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set lock status of the resizable panel group.
|
||||
pub fn lock(mut self, locked: bool) -> Self {
|
||||
self.locked = locked;
|
||||
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.
|
||||
/// - The `initial_size` will be set to the average size of all panels if not provided.
|
||||
/// - The `group` will be set to the group entity.
|
||||
pub fn child(mut self, panel: impl Into<ResizablePanel>) -> Self {
|
||||
self.children.push(panel.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple panels to the group.
|
||||
pub fn children<I>(mut self, panels: impl IntoIterator<Item = I>) -> Self
|
||||
where
|
||||
I: Into<ResizablePanel>,
|
||||
{
|
||||
self.children = panels.into_iter().map(|panel| panel.into()).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set size of the resizable panel group
|
||||
///
|
||||
/// - When the axis is horizontal, the size is the height of the group.
|
||||
/// - When the axis is vertical, the size is the width of the group.
|
||||
pub fn size(mut self, size: Pixels) -> Self {
|
||||
self.size = Some(size);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the callback to be called when the panels are resized.
|
||||
///
|
||||
/// ## Callback arguments
|
||||
///
|
||||
/// - Entity<ResizableState>: The state of the ResizablePanelGroup.
|
||||
pub fn on_resize(
|
||||
mut self,
|
||||
on_resize: impl Fn(&Entity<ResizableState>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_resize = Rc::new(on_resize);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<T> for ResizablePanel
|
||||
where
|
||||
T: Into<AnyElement>,
|
||||
{
|
||||
fn from(value: T) -> Self {
|
||||
resizable_panel().child(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ResizablePanelGroup> for ResizablePanel {
|
||||
fn from(value: ResizablePanelGroup) -> Self {
|
||||
resizable_panel().child(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ResizablePanelEvent> for ResizablePanelGroup {}
|
||||
|
||||
impl RenderOnce for ResizablePanelGroup {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = self.state.unwrap_or_else(|| {
|
||||
window.use_keyed_state(self.id.clone(), cx, |_, _| ResizableState::default())
|
||||
});
|
||||
let container = if self.axis.is_horizontal() {
|
||||
h_flex()
|
||||
} else {
|
||||
v_flex()
|
||||
};
|
||||
|
||||
// Sync panels to the state
|
||||
let panels_count = self.children.len();
|
||||
state.update(cx, |state, cx| {
|
||||
state.sync_panels_count(self.axis, panels_count, !self.independent_resize, cx);
|
||||
});
|
||||
|
||||
container
|
||||
.id(self.id)
|
||||
.size_full()
|
||||
.children(
|
||||
self.children
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(ix, mut panel)| {
|
||||
panel.panel_ix = ix;
|
||||
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
|
||||
}),
|
||||
)
|
||||
.on_prepaint({
|
||||
let state = state.clone();
|
||||
move |bounds, _, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
let size_changed =
|
||||
state.bounds.size.along(self.axis) != bounds.size.along(self.axis);
|
||||
|
||||
state.bounds = bounds;
|
||||
|
||||
if size_changed && !self.independent_resize {
|
||||
state.adjust_to_container_size(cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.child(ResizePanelGroupElement {
|
||||
state: state.clone(),
|
||||
axis: self.axis,
|
||||
on_resize: self.on_resize.clone(),
|
||||
independent_resize: self.independent_resize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A resizable panel inside a [`ResizablePanelGroup`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct ResizablePanel {
|
||||
axis: Axis,
|
||||
panel_ix: usize,
|
||||
state: Option<Entity<ResizableState>>,
|
||||
/// Initial size is the size that the panel has when it is created.
|
||||
initial_size: Option<Pixels>,
|
||||
/// size range limit of this panel.
|
||||
size_range: Range<Pixels>,
|
||||
children: Vec<AnyElement>,
|
||||
visible: bool,
|
||||
style: StyleRefinement,
|
||||
locked: bool,
|
||||
trailing_resize_handle: bool,
|
||||
}
|
||||
|
||||
impl ResizablePanel {
|
||||
/// Create a new resizable panel.
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
panel_ix: 0,
|
||||
initial_size: None,
|
||||
state: None,
|
||||
size_range: (PANEL_MIN_SIZE..Pixels::MAX),
|
||||
axis: Axis::Horizontal,
|
||||
children: vec![],
|
||||
visible: true,
|
||||
style: StyleRefinement::default(),
|
||||
locked: false,
|
||||
trailing_resize_handle: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the visibility of the panel, default is true.
|
||||
pub fn visible(mut self, visible: bool) -> Self {
|
||||
self.visible = visible;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the initial size of the panel.
|
||||
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||
self.initial_size = Some(size.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the size range to limit panel resize.
|
||||
///
|
||||
/// Default is [`PANEL_MIN_SIZE`] to [`Pixels::MAX`].
|
||||
pub fn size_range(mut self, range: impl Into<Range<Pixels>>) -> Self {
|
||||
self.size_range = range.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for ResizablePanel {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for ResizablePanel {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ResizablePanel {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
if !self.visible {
|
||||
return div().id(("resizable-panel", self.panel_ix));
|
||||
}
|
||||
|
||||
let state = self
|
||||
.state
|
||||
.expect("BUG: The `state` in ResizablePanel should be present.");
|
||||
let panel_state = state
|
||||
.read(cx)
|
||||
.panels
|
||||
.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))
|
||||
.flex()
|
||||
.flex_grow_1()
|
||||
.size_full()
|
||||
.relative()
|
||||
.refine_style(&self.style)
|
||||
.when(self.axis.is_vertical(), |this| {
|
||||
this.min_h(size_range.start).max_h(size_range.end)
|
||||
})
|
||||
.when(self.axis.is_horizontal(), |this| {
|
||||
this.min_w(size_range.start).max_w(size_range.end)
|
||||
})
|
||||
.when(self.initial_size.is_none(), |this| this.flex_shrink_1())
|
||||
.when_some(self.initial_size, |this, initial_size| {
|
||||
this.when(
|
||||
panel_state.size.is_none() && !initial_size.is_zero(),
|
||||
|this| this.flex_none(),
|
||||
)
|
||||
.flex_basis(initial_size)
|
||||
})
|
||||
.map(|this| match panel_state.size {
|
||||
Some(size) => this.flex_basis(size.min(size_range.end).max(size_range.start)),
|
||||
None => this,
|
||||
})
|
||||
.on_prepaint({
|
||||
let state = state.clone();
|
||||
move |bounds, _, cx| {
|
||||
state.update(cx, |state, cx| {
|
||||
state.update_panel_size(self.panel_ix, bounds, self.size_range, cx)
|
||||
})
|
||||
}
|
||||
})
|
||||
.children(self.children)
|
||||
.when(self.panel_ix > 0, |this| {
|
||||
let ix = self.panel_ix - 1;
|
||||
let handle = resize_handle(("resizable-handle", ix), self.axis);
|
||||
let handle = if self.locked {
|
||||
handle
|
||||
} else {
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ResizePanelGroupElement {
|
||||
state: Entity<ResizableState>,
|
||||
on_resize: ResizeCallback,
|
||||
axis: Axis,
|
||||
independent_resize: bool,
|
||||
}
|
||||
|
||||
impl IntoElement for ResizePanelGroupElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ResizePanelGroupElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<gpui::ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
(window.request_layout(Style::default(), None, cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&gpui::GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: Bounds<Pixels>,
|
||||
_: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) {
|
||||
window.on_mouse_event({
|
||||
let state = self.state.clone();
|
||||
let axis = self.axis;
|
||||
let independent_resize = self.independent_resize;
|
||||
move |e: &MouseMoveEvent, phase, window, cx| {
|
||||
if !phase.bubble() {
|
||||
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");
|
||||
|
||||
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();
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// When any mouse up, stop dragging
|
||||
window.on_mouse_event({
|
||||
let state = self.state.clone();
|
||||
let on_resize = self.on_resize.clone();
|
||||
move |_: &MouseUpEvent, phase, window, cx| {
|
||||
if state.read(cx).resizing_panel_ix.is_none() {
|
||||
return;
|
||||
}
|
||||
if phase.bubble() {
|
||||
state.update(cx, |state, cx| state.done_resizing(cx));
|
||||
on_resize(&state, window, cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
use std::{cell::Cell, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
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};
|
||||
|
||||
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>,
|
||||
axis: Axis,
|
||||
) -> ResizeHandle<T, E> {
|
||||
ResizeHandle::new(id, axis)
|
||||
}
|
||||
|
||||
pub(crate) struct ResizeHandle<T: 'static, E: 'static + Render> {
|
||||
id: ElementId,
|
||||
axis: Axis,
|
||||
drag_value: Option<Rc<T>>,
|
||||
placement: Option<DockPlacement>,
|
||||
on_drag: Option<DragCallback<E>>,
|
||||
}
|
||||
|
||||
impl<T: 'static, E: 'static + Render> ResizeHandle<T, E> {
|
||||
fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
|
||||
let id = id.into();
|
||||
Self {
|
||||
id: id.clone(),
|
||||
on_drag: None,
|
||||
drag_value: None,
|
||||
placement: None,
|
||||
axis,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_drag(
|
||||
mut self,
|
||||
value: T,
|
||||
f: impl Fn(Rc<T>, &Point<Pixels>, &mut Window, &mut App) -> Entity<E> + 'static,
|
||||
) -> Self {
|
||||
let value = Rc::new(value);
|
||||
self.drag_value = Some(value.clone());
|
||||
self.on_drag = Some(Rc::new(move |p, window, cx| {
|
||||
f(value.clone(), p, window, cx)
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn placement(mut self, placement: DockPlacement) -> Self {
|
||||
self.placement = Some(placement);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone)]
|
||||
struct ResizeHandleState {
|
||||
active: Cell<bool>,
|
||||
}
|
||||
|
||||
impl ResizeHandleState {
|
||||
fn set_active(&self, active: bool) {
|
||||
self.active.set(active);
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.active.get()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static, E: 'static + Render> IntoElement for ResizeHandle<T, E> {
|
||||
type Element = ResizeHandle<T, E>;
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: 'static, E: 'static + Render> Element for ResizeHandle<T, E> {
|
||||
type RequestLayoutState = AnyElement;
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
Some(self.id.clone())
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
id: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let neg_offset = -HANDLE_PADDING;
|
||||
let axis = self.axis;
|
||||
|
||||
window.with_element_state(id.unwrap(), |state, window| {
|
||||
let state: ResizeHandleState = state.unwrap_or_default();
|
||||
|
||||
let bg_color = if state.is_active() {
|
||||
cx.theme().drag_border
|
||||
} else {
|
||||
cx.theme().border
|
||||
};
|
||||
|
||||
let has_drag = self.on_drag.is_some();
|
||||
|
||||
let mut el = div()
|
||||
.id(self.id.clone())
|
||||
.occlude()
|
||||
.absolute()
|
||||
.flex_shrink_0()
|
||||
.group("handle")
|
||||
.when_some(self.on_drag.clone(), |this, on_drag| {
|
||||
this.on_drag(
|
||||
self.drag_value.clone().unwrap(),
|
||||
move |_, position, window, cx| on_drag(&position, window, cx),
|
||||
)
|
||||
})
|
||||
.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),
|
||||
_ => this
|
||||
.when(axis.is_horizontal(), |this| {
|
||||
this.when(has_drag, |this| this.cursor_col_resize())
|
||||
.top_0()
|
||||
.left(neg_offset)
|
||||
.h_full()
|
||||
.w(HANDLE_SIZE)
|
||||
.px(HANDLE_PADDING)
|
||||
})
|
||||
.when(axis.is_vertical(), |this| {
|
||||
this.when(has_drag, |this| this.cursor_row_resize())
|
||||
.top(neg_offset)
|
||||
.left_0()
|
||||
.w_full()
|
||||
.h(HANDLE_SIZE)
|
||||
.py(HANDLE_PADDING)
|
||||
}),
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.bg(bg_color)
|
||||
.group_hover("handle", |this| this.bg(bg_color))
|
||||
.when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE))
|
||||
.when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)),
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
let layout_id = el.request_layout(window, cx);
|
||||
|
||||
((layout_id, el), state)
|
||||
})
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
_: gpui::Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
request_layout.prepaint(window, cx);
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
id: Option<&GlobalElementId>,
|
||||
_: Option<&gpui::InspectorElementId>,
|
||||
bounds: gpui::Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
_: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
request_layout.paint(window, cx);
|
||||
|
||||
window.with_element_state(id.unwrap(), |state: Option<ResizeHandleState>, window| {
|
||||
let state = state.unwrap_or_default();
|
||||
|
||||
if self.on_drag.is_some() {
|
||||
window.on_mouse_event({
|
||||
let state = state.clone();
|
||||
move |ev: &MouseDownEvent, phase, window, _| {
|
||||
if bounds.contains(&ev.position) && phase.bubble() {
|
||||
state.set_active(true);
|
||||
window.refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
window.on_mouse_event({
|
||||
let state = state.clone();
|
||||
move |_: &MouseUpEvent, _, window, _| {
|
||||
if state.is_active() {
|
||||
state.set_active(false);
|
||||
window.refresh();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
((), state)
|
||||
});
|
||||
}
|
||||
}
|
||||
+99
-132
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+134
-34
@@ -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());
|
||||
@@ -162,6 +230,12 @@ pub(crate) fn sync_macos_launch_environment() {
|
||||
| "HOMEBREW_PREFIX"
|
||||
| "HOMEBREW_CELLAR"
|
||||
| "HOMEBREW_REPOSITORY"
|
||||
| "HTTP_PROXY"
|
||||
| "HTTPS_PROXY"
|
||||
| "ALL_PROXY"
|
||||
| "http_proxy"
|
||||
| "https_proxy"
|
||||
| "all_proxy"
|
||||
) || key.starts_with("LC_");
|
||||
|
||||
if should_import {
|
||||
@@ -172,12 +246,63 @@ 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",
|
||||
];
|
||||
for var in vars {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
if val.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(url) = reqwest::Url::parse(&val) {
|
||||
let scheme = url.scheme();
|
||||
let proxy_type = match scheme {
|
||||
"socks5" | "socks5h" => "socks5".to_string(),
|
||||
"http" | "https" => "http".to_string(),
|
||||
_ => "socks5".to_string(),
|
||||
};
|
||||
let host = url.host_str().unwrap_or("").to_string();
|
||||
let port = url.port();
|
||||
let user = url.username().to_string();
|
||||
let password = url.password().unwrap_or("").to_string();
|
||||
return Some((proxy_type, host, port, user, password));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn sync_macos_launch_environment() {}
|
||||
|
||||
pub(crate) fn open_main_window(cx: &mut App) {
|
||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
||||
|
||||
let _ = crate::session::config::ENV_PROXY.get_or_init(|| {
|
||||
read_proxy_from_env().map(|(proxy_type, host, port, user, password)| {
|
||||
tracing::info!(
|
||||
"[proxy] Loaded proxy configuration from environment: type={}, host={}, port={:?}, user={}",
|
||||
proxy_type,
|
||||
host,
|
||||
port,
|
||||
user
|
||||
);
|
||||
crate::session::config::EnvProxy {
|
||||
proxy_type,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
pass: password,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let mut window_options = WindowOptions::default();
|
||||
|
||||
if config.title_bar_style() == crate::session::config::TitleBarStyle::Integrated {
|
||||
@@ -194,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;
|
||||
@@ -246,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
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
)]),
|
||||
]);
|
||||
}
|
||||
+87
-17
@@ -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;
|
||||
|
||||
@@ -12,17 +13,73 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
|
||||
include_str!("../../assets/themes/phygerr.json"),
|
||||
];
|
||||
|
||||
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 regular = std::borrow::Cow::Borrowed(
|
||||
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
|
||||
);
|
||||
let bold = std::borrow::Cow::Borrowed(
|
||||
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
|
||||
);
|
||||
cx.text_system()
|
||||
.add_fonts(vec![regular, bold])
|
||||
.context("load Maple Mono NF CN fonts")?;
|
||||
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
|
||||
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 {
|
||||
let regular = std::borrow::Cow::Borrowed(
|
||||
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice(),
|
||||
);
|
||||
let bold = std::borrow::Cow::Borrowed(
|
||||
include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice(),
|
||||
);
|
||||
cx.text_system()
|
||||
.add_fonts(vec![regular, bold])
|
||||
.context("load Maple Mono NF CN fonts")?;
|
||||
}
|
||||
let safe_font = resolve_safe_ui_font(cx, ".SystemUIFont");
|
||||
set_theme_font_names(cx.global_mut::<Theme>(), &safe_font);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -114,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();
|
||||
}
|
||||
@@ -132,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);
|
||||
@@ -156,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();
|
||||
}
|
||||
}
|
||||
|
||||
+4199
-1311
File diff suppressed because it is too large
Load Diff
@@ -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
-16
@@ -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,8 +625,10 @@ pub fn spawn_local_terminal(
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
BackendCommand::SampleMetrics => {}
|
||||
BackendCommand::PromptResponse(_) => {}
|
||||
BackendCommand::SampleMetrics
|
||||
| BackendCommand::SampleProcesses
|
||||
| BackendCommand::SamplePorts
|
||||
| BackendCommand::TerminateProcess { .. } => {}
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
@@ -130,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();
|
||||
});
|
||||
@@ -141,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,2 +1,3 @@
|
||||
pub mod local;
|
||||
pub mod serial;
|
||||
pub mod ssh;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+721
-273
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
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, ConnectionProgress, 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",
|
||||
|
||||
+1584
-57
File diff suppressed because it is too large
Load Diff
+1787
-349
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+543
-428
File diff suppressed because it is too large
Load Diff
+164
-34
@@ -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, ¤t_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(¤t_path);
|
||||
let mut paths_to_load = Vec::new();
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.expand_to(¤t_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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,5 +335,3 @@ pub fn paint_custom_block(
|
||||
|
||||
painted
|
||||
}
|
||||
|
||||
|
||||
|
||||
+340
-71
@@ -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)]
|
||||
@@ -39,6 +40,26 @@ impl LayoutRect {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LayoutUnderline {
|
||||
row: i32,
|
||||
col: i32,
|
||||
cells: usize,
|
||||
color: Hsla,
|
||||
}
|
||||
|
||||
impl LayoutUnderline {
|
||||
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window) {
|
||||
let thickness = px(1.0);
|
||||
let position = point(
|
||||
origin.x + metrics.cell_width * self.col as f32,
|
||||
origin.y + metrics.line_height * (self.row as f32 + 1.0) - thickness,
|
||||
);
|
||||
let size = gpui::size(metrics.cell_width * self.cells as f32, thickness);
|
||||
window.paint_quad(fill(Bounds::new(position, size), self.color));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BatchedTextRun {
|
||||
row: i32,
|
||||
@@ -126,15 +147,31 @@ struct CursorLayout {
|
||||
pub struct TerminalElement {
|
||||
view: Entity<Ashell>,
|
||||
focus_handle: FocusHandle,
|
||||
pane_focused: bool,
|
||||
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>>,
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -142,6 +179,7 @@ pub struct PrepaintState {
|
||||
runs: Vec<BatchedTextRun>,
|
||||
custom_blocks: Vec<LayoutCustomBlock>,
|
||||
cursor: Option<CursorLayout>,
|
||||
underlines: Vec<LayoutUnderline>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -262,26 +300,31 @@ 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,
|
||||
search_highlights: Option<std::collections::HashMap<(i32, i32), Hsla>>,
|
||||
) -> Self {
|
||||
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,
|
||||
font_size,
|
||||
line_height,
|
||||
cell_width,
|
||||
tab_id,
|
||||
search_highlights,
|
||||
}
|
||||
}
|
||||
@@ -296,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),
|
||||
});
|
||||
@@ -318,7 +356,7 @@ impl TerminalElement {
|
||||
.flags
|
||||
.contains(Flags::STRIKEOUT)
|
||||
.then(|| StrikethroughStyle {
|
||||
color: Some(fg),
|
||||
color: Some(foreground),
|
||||
thickness: px(1.0),
|
||||
});
|
||||
|
||||
@@ -335,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(),
|
||||
@@ -351,18 +389,21 @@ impl TerminalElement {
|
||||
fn layout_grid(
|
||||
&self,
|
||||
cx: &App,
|
||||
) -> (Vec<LayoutRect>, Vec<BatchedTextRun>, Vec<LayoutCustomBlock>) {
|
||||
) -> (
|
||||
Vec<LayoutRect>,
|
||||
Vec<BatchedTextRun>,
|
||||
Vec<LayoutCustomBlock>,
|
||||
Vec<LayoutUnderline>,
|
||||
) {
|
||||
let view_read = self.view.read(cx);
|
||||
let hovered_url = view_read.hovered_url.clone();
|
||||
|
||||
let mut rects = Vec::new();
|
||||
let mut runs = Vec::new();
|
||||
let mut custom_blocks = Vec::new();
|
||||
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(
|
||||
@@ -374,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) {
|
||||
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) {
|
||||
@@ -401,11 +472,26 @@ impl TerminalElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut style = self.cell_run_style(cell, cx);
|
||||
let style = self.cell_run_style(cell, foreground);
|
||||
|
||||
// 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;
|
||||
// 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))
|
||||
{
|
||||
underlines.push(LayoutUnderline {
|
||||
row: render_cell.row,
|
||||
col: render_cell.col,
|
||||
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
color: style.color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Box Drawing & Block Elements interception
|
||||
@@ -460,19 +546,62 @@ impl TerminalElement {
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
(merge_rects(rects), runs, custom_blocks)
|
||||
(
|
||||
merge_rects(rects),
|
||||
runs,
|
||||
custom_blocks,
|
||||
merge_underlines(underlines),
|
||||
)
|
||||
}
|
||||
|
||||
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
|
||||
self.snapshot.cursor.map(|cursor| CursorLayout {
|
||||
row: cursor.row,
|
||||
col: cursor.col,
|
||||
shape: cursor.shape,
|
||||
color: cx.theme().primary,
|
||||
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)
|
||||
{
|
||||
(duration.as_millis() / 600) % 2 == 0
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
|
||||
self.snapshot.cursor.map(|cursor| {
|
||||
let mut shape = match cursor_style {
|
||||
CursorStyle::Default => cursor.shape,
|
||||
CursorStyle::Blink => CursorShape::Block,
|
||||
CursorStyle::Beam => CursorShape::Beam,
|
||||
CursorStyle::BeamBlink => CursorShape::Beam,
|
||||
};
|
||||
if !show_cursor {
|
||||
shape = CursorShape::Hidden;
|
||||
}
|
||||
CursorLayout {
|
||||
row: cursor.row,
|
||||
col: cursor.col,
|
||||
shape,
|
||||
color: cx.theme().primary,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -512,11 +641,31 @@ 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) = self.layout_grid(cx);
|
||||
|
||||
// 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 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,
|
||||
metrics: TerminalMetrics {
|
||||
@@ -526,7 +675,8 @@ impl Element for TerminalElement {
|
||||
rects,
|
||||
runs,
|
||||
custom_blocks,
|
||||
cursor: self.cursor_layout(cx),
|
||||
cursor: self.cursor_layout(window, cx),
|
||||
underlines,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,19 +690,34 @@ impl Element for TerminalElement {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
// 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 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,
|
||||
);
|
||||
|
||||
for rect in &prepaint.rects {
|
||||
rect.paint(prepaint.bounds.origin, prepaint.metrics, window);
|
||||
rect.paint(draw_origin, prepaint.metrics, window);
|
||||
}
|
||||
|
||||
for run in &prepaint.runs {
|
||||
run.paint(prepaint.bounds.origin, prepaint.metrics, window, cx);
|
||||
run.paint(draw_origin, prepaint.metrics, window, cx);
|
||||
}
|
||||
|
||||
for u in &prepaint.underlines {
|
||||
u.paint(draw_origin, prepaint.metrics, window);
|
||||
}
|
||||
|
||||
for block in &prepaint.custom_blocks {
|
||||
let x = prepaint.bounds.origin.x.as_f32()
|
||||
+ block.col as f32 * prepaint.metrics.cell_width.as_f32();
|
||||
let y = prepaint.bounds.origin.y.as_f32()
|
||||
+ block.row as f32 * prepaint.metrics.line_height.as_f32();
|
||||
let x =
|
||||
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,
|
||||
@@ -568,7 +733,7 @@ impl Element for TerminalElement {
|
||||
&self.focus_handle,
|
||||
TerminalInputHandler {
|
||||
view: self.view.clone(),
|
||||
element_bounds: prepaint.bounds,
|
||||
element_bounds: Bounds::new(draw_origin, prepaint.bounds.size),
|
||||
cell_width: prepaint.metrics.cell_width.as_f32(),
|
||||
line_height: prepaint.metrics.line_height.as_f32(),
|
||||
},
|
||||
@@ -578,8 +743,8 @@ impl Element for TerminalElement {
|
||||
if let Some(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
|
||||
if let Some(cursor) = prepaint.cursor {
|
||||
let pos = point(
|
||||
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
|
||||
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
|
||||
draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32,
|
||||
draw_origin.y + prepaint.metrics.line_height * cursor.row as f32,
|
||||
);
|
||||
let mut base_style = self.base_text_style(cx);
|
||||
base_style.underline = Some(UnderlineStyle {
|
||||
@@ -626,8 +791,8 @@ impl Element for TerminalElement {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
|
||||
let y = prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32;
|
||||
let x = draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32;
|
||||
let y = draw_origin.y + prepaint.metrics.line_height * cursor.row as f32;
|
||||
match cursor.shape {
|
||||
CursorShape::Hidden => {}
|
||||
CursorShape::Beam => {
|
||||
@@ -687,6 +852,23 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
|
||||
merged
|
||||
}
|
||||
|
||||
fn merge_underlines(mut underlines: Vec<LayoutUnderline>) -> Vec<LayoutUnderline> {
|
||||
underlines.sort_by_key(|u| (u.row, u.col));
|
||||
let mut merged: Vec<LayoutUnderline> = Vec::with_capacity(underlines.len());
|
||||
|
||||
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 {
|
||||
last.cells += u.cells;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.push(u);
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
|
||||
let row = row.max(0) as usize;
|
||||
let col = col.max(0) as usize;
|
||||
@@ -704,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()
|
||||
@@ -795,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);
|
||||
}
|
||||
}
|
||||
|
||||
+1099
-170
File diff suppressed because it is too large
Load Diff
+1161
-40
File diff suppressed because it is too large
Load Diff
+2062
-63
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user