mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare commits
193
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ec91d099 | ||
|
|
e2501ca764 | ||
|
|
a34a673b52 | ||
|
|
0708ac7baa | ||
|
|
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 | ||
|
|
3c6b9e3b46 | ||
|
|
a88379b01c | ||
|
|
f322b2c717 | ||
|
|
45d0c64c06 | ||
|
|
6312a482cb | ||
|
|
8bb4cbc655 | ||
|
|
847f0626f4 | ||
|
|
7cfe5c64d5 | ||
|
|
6d23d27fea | ||
|
|
da856ac9d7 | ||
|
|
7653c66a6f | ||
|
|
56fe508122 | ||
|
|
3b7165a4bd | ||
|
|
5ae44c9e44 | ||
|
|
73938aef8a | ||
|
|
59df2b5a9a | ||
|
|
b225b0e56f | ||
|
|
2cdd6d3ded | ||
|
|
42c408f63f | ||
|
|
f4005bf2bb | ||
|
|
d5fab2e9ca | ||
|
|
1b7f625aea | ||
|
|
b6b4d3c35e | ||
|
|
ae763eae1c | ||
|
|
a89d15d255 | ||
|
|
b5a068541d | ||
|
|
7be0c420e1 | ||
|
|
62028c7921 | ||
|
|
56a6ef98b7 | ||
|
|
caa8a58561 | ||
|
|
6150bd410b | ||
|
|
f55c3a8470 | ||
|
|
7ae44ab9a7 | ||
|
|
9c43b2dde7 | ||
|
|
e6134bc0d1 | ||
|
|
96cd85a8d3 | ||
|
|
f45c2ed60f | ||
|
|
8a8f280401 | ||
|
|
dc006f7144 | ||
|
|
daaebe4c2b | ||
|
|
2a37f45ba2 | ||
|
|
ee8c18844c | ||
|
|
c5b09628b3 | ||
|
|
b45364331d | ||
|
|
2d92f0ceee | ||
|
|
162a245a8f | ||
|
|
68bf152792 | ||
|
|
13b9433c90 | ||
|
|
445e474fe8 | ||
|
|
4c7c06f391 | ||
|
|
e4fbd65a31 | ||
|
|
bd376935f4 | ||
|
|
16761c1bf5 | ||
|
|
c3201e7c30 | ||
|
|
2d5062ed75 | ||
|
|
d7d6faeeb1 | ||
|
|
19a4274473 | ||
|
|
4bb3bee6c8 | ||
|
|
8ab4b43825 | ||
|
|
bbf843dde6 | ||
|
|
bc6af2e213 | ||
|
|
d224506fd5 | ||
|
|
d175135529 | ||
|
|
c769b982b4 | ||
|
|
ee68835972 | ||
|
|
8a535fad5a | ||
|
|
90c07e16e1 | ||
|
|
33a3d624b7 | ||
|
|
78f8626217 | ||
|
|
743a5f9fe7 | ||
|
|
99a954b0b5 | ||
|
|
0bd04a98f0 | ||
|
|
df44d17ade | ||
|
|
0e4c4cdc69 | ||
|
|
227966319c | ||
|
|
205ceba20c | ||
|
|
0ccbf9573b | ||
|
|
8193223dc2 | ||
|
|
805cec5000 | ||
|
|
3151757c4f | ||
|
|
57c55e358c | ||
|
|
81b160285d | ||
|
|
77206bd126 | ||
|
|
bb22718d3d | ||
|
|
4ba3000538 | ||
|
|
e7e2c67393 | ||
|
|
47b03510b2 | ||
|
|
54c50c871a | ||
|
|
6f887712e6 | ||
|
|
99071997d5 | ||
|
|
e35a2458bb | ||
|
|
690d7418f4 | ||
|
|
aca03c924f | ||
|
|
1847736d66 | ||
|
|
e163d05683 | ||
|
|
c3b1707014 | ||
|
|
8a82288c5e | ||
|
|
8c24eb885c | ||
|
|
2afcd1ea90 | ||
|
|
68439bc599 | ||
|
|
f1186e4c84 | ||
|
|
2595cc4853 | ||
|
|
2ae28df8f8 | ||
|
|
58fd03386a | ||
|
|
890ce85cfc | ||
|
|
d5fb956161 | ||
|
|
4bad75e522 | ||
|
|
9e28e78319 | ||
|
|
d561232d19 | ||
|
|
107fe8942c | ||
|
|
06796031e6 | ||
|
|
0cfd3a2fc2 | ||
|
|
7504bf6448 | ||
|
|
09ecd67ca6 | ||
|
|
6dfa199dcb | ||
|
|
4c5148fccf | ||
|
|
108a9cf95b | ||
|
|
9885b2573a | ||
|
|
cdf4cc3d53 | ||
|
|
5b381045f9 | ||
|
|
f246c952a2 | ||
|
|
2276eef191 | ||
|
|
bb39626173 | ||
|
|
2cb8ab50aa |
@@ -0,0 +1,64 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-2022
|
||||
target: x86_64-pc-windows-msvc
|
||||
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-arm64
|
||||
- os: macos-14 # Intel (cross-compiled from Apple Silicon to avoid queue delays)
|
||||
target: x86_64-apple-darwin
|
||||
name: macos-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# GPUI needs these system libs on Linux to compile.
|
||||
- name: Install Linux GUI dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config cmake \
|
||||
libfontconfig1-dev libfreetype6-dev \
|
||||
libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
|
||||
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libgtk-3-dev \
|
||||
libudev-dev
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build (release)
|
||||
# We build in release mode so that the release workflow can reuse the exact artifacts
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
+454
-52
@@ -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
|
||||
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: ubuntu-22.04 # build on the oldest supported runner for broader glibc compatibility
|
||||
- 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
|
||||
- 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)
|
||||
- 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,27 +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.
|
||||
|
||||
APP="ashell.app"
|
||||
CONTENTS="$APP/Contents"
|
||||
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
||||
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
|
||||
|
||||
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
|
||||
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-${{ matrix.name }}" >> "$GITHUB_ENV"
|
||||
|
||||
VERSION_NUM="${VERSION#v}"
|
||||
cat > "$CONTENTS/Info.plist" <<EOF
|
||||
- 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"
|
||||
|
||||
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">
|
||||
@@ -98,6 +155,8 @@ jobs:
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ashell</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>ashell.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.ashell.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
@@ -107,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>
|
||||
@@ -118,39 +177,382 @@ 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
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all workflow artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Attach to GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ env.ASSET }}
|
||||
files: dist/*
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
cask:
|
||||
name: Update Homebrew Cask
|
||||
needs: publish
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Homebrew Tap
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: rust-kotlin/homebrew-taps
|
||||
token: ${{ secrets.TAP_GITHUB_TOKEN }}
|
||||
path: tap
|
||||
|
||||
- name: Download all macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: macos-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Update Cask
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
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"
|
||||
|
||||
cat > "$CASK_FILE" <<EOF
|
||||
cask "ashell" do
|
||||
version "${VERSION}"
|
||||
|
||||
on_arm do
|
||||
sha256 "${SHA_ARM}"
|
||||
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-arm64.dmg"
|
||||
end
|
||||
on_intel do
|
||||
sha256 "${SHA_INTEL}"
|
||||
url "https://github.com/rust-kotlin/ashell/releases/download/v#{version}/ashell-v#{version}-macos-x64.dmg"
|
||||
end
|
||||
|
||||
name "ashell"
|
||||
desc "ashell is a modern, fast, GPUI Component-based desktop terminal client written in Rust."
|
||||
homepage "https://github.com/rust-kotlin/ashell"
|
||||
|
||||
app "ashell.app"
|
||||
|
||||
postflight do
|
||||
puts "Removing quarantine flag from ashell.app..."
|
||||
system_command "/usr/bin/xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "#{appdir}/ashell.app"],
|
||||
sudo: true
|
||||
end
|
||||
|
||||
caveats <<~EOS
|
||||
ashell uses ad-hoc signing. The installation includes a postflight script
|
||||
that removes the 'com.apple.quarantine' attribute from the application
|
||||
to allow it to run. This will require your administrator password.
|
||||
|
||||
ashell 采用本地签名。安装程序包含一个后置脚本,用于移除应用程序的
|
||||
'com.apple.quarantine' 属性以允许其运行。这需要您的管理员密码。
|
||||
EOS
|
||||
end
|
||||
EOF
|
||||
# Remove the 10 leading spaces added for YAML formatting
|
||||
sed -i 's/^ //' "$CASK_FILE"
|
||||
|
||||
- name: Commit and Push
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
cd tap
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
git add Casks/ashell.rb
|
||||
git commit -m "chore: bump ashell to v${VERSION}"
|
||||
git push
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
|
||||
+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
+956
-82
File diff suppressed because it is too large
Load Diff
+69
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ashell"
|
||||
version = "0.1.0"
|
||||
version = "0.5.2-fix1"
|
||||
edition = "2024"
|
||||
authors = ["ashell contributors"]
|
||||
description = "A GPUI Component based SSH and local terminal client"
|
||||
@@ -9,13 +9,16 @@ 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"] }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
|
||||
gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "ca85b5588f5feea021f1ee86cb13703dc6f30ec4" }
|
||||
menu = { git = "https://github.com/zed-industries/zed" }
|
||||
@@ -24,21 +27,84 @@ rfd = "0.15"
|
||||
russh = "0.49"
|
||||
russh-keys = "0.49"
|
||||
russh-sftp = "2"
|
||||
rust-i18n = "3"
|
||||
sys-locale = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ssh-key = "0.6"
|
||||
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"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time", "time"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
walkdir = "2"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
image = { version = "0.25.9", default-features = false, features = ["png"] }
|
||||
futures = "0.3.32"
|
||||
notify = "6.1.1"
|
||||
open = "5.1"
|
||||
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
|
||||
tracing-appender = "0.2.5"
|
||||
argon2 = "0.5"
|
||||
base64 = "0.22"
|
||||
chacha20poly1305 = "0.10"
|
||||
hmac = "0.12"
|
||||
hex = "0.4"
|
||||
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"
|
||||
codegen-units = 1
|
||||
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" }
|
||||
|
||||
+111
-3
@@ -8,19 +8,66 @@
|
||||
|
||||
This project focuses on providing a high-performance and visually appealing shell workspace by combining local and remote environments with a rich set of built-in features.
|
||||
|
||||
## 🚀 v0.4 Major Upgrades
|
||||
|
||||
v0.4 builds on the v0.3 foundation and focuses on more capable workspace operations and a smoother daily workflow:
|
||||
- ✨ **Keybinding Management**: View and edit common shortcuts from the settings UI with conflict hints.
|
||||
- ✨ **Settings Page Polish**: A more compact and clearer settings experience with improved layout and interaction flow.
|
||||
- ✨ **Multi-Pane Tabs with a tmux-like Workflow**: A single tab can now host multiple panes, with split, focus, and switching actions for a tmux-inspired experience.
|
||||
- ✨ **Transfer History Improvements**: The transfer history panel now presents richer task details, making upload and download activity easier to review.
|
||||
- ✨ **SSH Passphrase Support**: Private keys can now store a passphrase, and SSH connections will use it automatically.
|
||||
- ✨ **Terminal Rendering Enhancements**: Terminal rendering now handles Block Elements and similar custom glyphs more completely.
|
||||
|
||||
## Download
|
||||
|
||||
You can download the latest pre-compiled releases for macOS and Windows from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
|
||||
You can download the latest 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
|
||||
|
||||
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
|
||||
Since the app uses ad-hoc signing, macOS may warn that the app is "damaged" upon first launch. If this happens, open Terminal and run the following command:
|
||||
### Method 1: Homebrew (Recommended)
|
||||
|
||||
If you use [Homebrew](https://brew.sh/), you can install it quickly with:
|
||||
|
||||
```bash
|
||||
brew install rust-kotlin/taps/ashell --cask
|
||||
```
|
||||
|
||||
To update the app:
|
||||
|
||||
```bash
|
||||
brew update
|
||||
brew upgrade ashell --cask
|
||||
```
|
||||
|
||||
> **Note**: Since the app uses ad-hoc signing, the Homebrew installation or update includes a postflight script to automatically handle the quarantine flag. This will require you to enter your administrator password for authorization.
|
||||
|
||||
### Method 2: Manual Download
|
||||
|
||||
1. Download 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:
|
||||
@@ -33,6 +80,7 @@ The current version provides a fully-featured GPUI-native workspace:
|
||||
- **System Telemetry:** Real-time visualization of CPU, memory, swap, network, and disk metrics in the left cockpit sidebar.
|
||||
- **Theming System:** Switch between multiple GPUI Component themes directly from the top toolbar.
|
||||
- **Embedded Fonts:** Uses embedded Maple Mono NF CN fonts out-of-the-box for excellent CJK character and Nerd Font icon support.
|
||||
- **v0.3 Core Enhancements:** Global font and font-size controls, concurrent SFTP transfers, persistent layout state, disconnect awareness, hot-swappable i18n, and smart terminal right-click copy/paste.
|
||||
|
||||
## Run
|
||||
|
||||
@@ -42,6 +90,33 @@ To run the application locally:
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
## Codex and Terminal Completion Notifications
|
||||
|
||||
ashell supports generic OSC 9 completion notifications. For Codex CLI, add the following to
|
||||
`~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
notifications = ["agent-turn-complete", "approval-requested"]
|
||||
notification_method = "osc9"
|
||||
notification_condition = "unfocused"
|
||||
```
|
||||
|
||||
When an eligible notification originates in the background or a non-visible tab, ashell shows a
|
||||
native notification and a flashing bell on the corresponding tab. Unread notifications also add a
|
||||
red badge to the macOS Dock icon or a red overlay to the Windows taskbar icon. Clicking the native
|
||||
notification activates ashell and switches to the originating terminal tab; viewing that tab clears
|
||||
its unread state. No reminder is shown when ashell is active and the notification originates in the
|
||||
currently visible tab. An unselected tab shows a loading indicator beside its title for two seconds
|
||||
after receiving terminal output. Continued output refreshes the indicator, while the selected tab
|
||||
does not show it.
|
||||
|
||||
You can test a generic OSC 9 notification with:
|
||||
|
||||
```bash
|
||||
printf '\033]9;Task completed\007'
|
||||
```
|
||||
|
||||
## Package macOS App
|
||||
|
||||
```bash
|
||||
@@ -51,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).
|
||||
|
||||
@@ -8,19 +8,66 @@
|
||||
|
||||
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
||||
|
||||
## 🚀 v0.4 版本重要升级
|
||||
|
||||
v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能力和更顺手的日常体验:
|
||||
- ✨ **键位管理功能**:支持在设置中可视化查看和修改常用快捷键,并提供冲突提示。
|
||||
- ✨ **设置页优化**:设置页的布局和交互更加紧凑顺手,整体信息层级更清晰。
|
||||
- ✨ **Tab 内多 Pane 与类 tmux 体验**:一个 tab 内现在可以管理多个 pane,支持分屏、聚焦和切换,提供更接近 tmux 的工作流。
|
||||
- ✨ **传输历史增强**:传输历史面板展示更完整的任务信息,便于回溯上传和下载过程。
|
||||
- ✨ **SSH passphrase 支持**:现在可以为私钥填写 passphrase,并在连接时自动使用保存的口令。
|
||||
- ✨ **终端显示优化**:终端渲染进一步增强,支持 Block Elements 等自定义图形字符的更完整显示。
|
||||
|
||||
## 下载
|
||||
|
||||
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS 和 Windows 版本的最新预编译程序。
|
||||
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载最新安装包:
|
||||
|
||||
- macOS:x64 和 arm64 的 `.dmg`,同时提供便携 `.zip`。
|
||||
- Windows:x64、x86 和 arm64 的 `Setup.exe`,同时提供便携 `.zip`。
|
||||
- Linux:x64 便携 `.tar.gz`。
|
||||
|
||||
## Mac 安装指南
|
||||
|
||||
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
|
||||
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
|
||||
### 方法 1: Homebrew 安装 (推荐)
|
||||
|
||||
如果您使用 [Homebrew](https://brew.sh/),可以通过以下命令快速安装:
|
||||
|
||||
```bash
|
||||
brew install rust-kotlin/taps/ashell --cask
|
||||
```
|
||||
|
||||
更新应用:
|
||||
|
||||
```bash
|
||||
brew update
|
||||
brew upgrade ashell --cask
|
||||
```
|
||||
|
||||
> **注意**: 由于应用采用本地签名,Homebrew 在安装或更新过程中会执行一个后置脚本来自动处理隔离属性(quarantine flag),这需要您输入管理员密码以授权。
|
||||
|
||||
### 方法 2: 手动下载
|
||||
|
||||
1. 从 [Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载与处理器匹配的 `.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 原生工作区:
|
||||
@@ -33,6 +80,7 @@ sudo xattr -cr /Applications/ashell.app
|
||||
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
|
||||
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
|
||||
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
|
||||
- **v0.3 核心增强**:全局字体与字号可调,支持 SFTP 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -42,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
|
||||
@@ -51,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
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
@@ -0,0 +1,308 @@
|
||||
{
|
||||
"$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json",
|
||||
"name": "Phyger",
|
||||
"author": "phygerr",
|
||||
"url": "https://github.com/phygerr",
|
||||
"themes": [
|
||||
{
|
||||
"name": "Phyger Light",
|
||||
"mode": "light",
|
||||
"colors": {
|
||||
"accent.background": "#D6E4F0",
|
||||
"accent.foreground": "#1B3A5C",
|
||||
"background": "#F8F9FC",
|
||||
"border": "#D0D5DD",
|
||||
"danger.background": "#D94848",
|
||||
"danger.active.background": "#B53535",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"foreground": "#2B2D30",
|
||||
"input.border": "#C0C7D0",
|
||||
"link.active.foreground": "#2563EB",
|
||||
"link.foreground": "#2563EB",
|
||||
"link.hover.foreground": "#1D4ED8",
|
||||
"list.active.background": "#2563EB18",
|
||||
"list.hover.background": "#E8EDF4",
|
||||
"list.active.border": "#2563EB",
|
||||
"list.even.background": "#F0F2F7",
|
||||
"muted.background": "#E2E6EE88",
|
||||
"muted.foreground": "#7A7F88",
|
||||
"panel.background": "#ECEEF3",
|
||||
"popover.background": "#F8F9FC",
|
||||
"popover.foreground": "#2B2D30",
|
||||
"primary.active.background": "#1D4ED8",
|
||||
"primary.background": "#2563EB",
|
||||
"primary.foreground": "#FFFFFF",
|
||||
"primary.hover.background": "#3B72F0",
|
||||
"ring": "#2563EB",
|
||||
"scrollbar.background": "#F8F9FC00",
|
||||
"scrollbar.thumb.background": "#C0C7D0",
|
||||
"secondary.background": "#E8EDF4",
|
||||
"secondary.active.background": "#D0D5DD",
|
||||
"secondary.foreground": "#2B2D30",
|
||||
"secondary.hover.background": "#DCE2EB",
|
||||
"tab.active.background": "#F8F9FC",
|
||||
"tab.active.foreground": "#2B2D30",
|
||||
"tab.background": "#ECEEF300",
|
||||
"tab.foreground": "#7A7F88",
|
||||
"tab_bar.background": "#ECEEF3",
|
||||
"title_bar.background": "#ECEEF3",
|
||||
"title_bar.border": "#D0D5DD",
|
||||
"base.magenta.light": "#C2185B44",
|
||||
"base.red": "#D32F2F",
|
||||
"base.green": "#2E7D32",
|
||||
"base.yellow": "#BF8C00",
|
||||
"base.blue": "#1565C0",
|
||||
"base.magenta": "#8E24AA",
|
||||
"base.cyan": "#00838F"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#2B2D30",
|
||||
"editor.background": "#F8F9FC",
|
||||
"editor.active_line.background": "#E8EDF4",
|
||||
"editor.line_number": "#A0A5AE",
|
||||
"editor.active_line_number": "#2B2D30",
|
||||
"editor.invisible": "#A0A5AE55",
|
||||
"conflict": "#D32F2F",
|
||||
"created": "#2E7D32",
|
||||
"deleted": "#C62828",
|
||||
"error": "#D32F2F",
|
||||
"hidden": "#A0A5AE",
|
||||
"hint": "#1565C0",
|
||||
"ignored": "#A0A5AE",
|
||||
"info": "#00838F",
|
||||
"modified": "#BF8C00",
|
||||
"predictive": "#A0A5AE",
|
||||
"renamed": "#BF8C00",
|
||||
"success": "#2E7D32",
|
||||
"unreachable": "#A0A5AE",
|
||||
"warning": "#E65100",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#7B1FA2"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#6A737D",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#6A737D",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#2B2D30"
|
||||
},
|
||||
"function": {
|
||||
"color": "#6A1B9A"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#1565C0",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#00838F",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#1B5E20"
|
||||
},
|
||||
"string": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#BF360C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#1565C0"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#00838F"
|
||||
},
|
||||
"title": {
|
||||
"color": "#6A1B9A",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#2E7D32"
|
||||
},
|
||||
"property": {
|
||||
"color": "#2B2D30"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#D32F2F"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Phyger Dark",
|
||||
"mode": "dark",
|
||||
"colors": {
|
||||
"accent.background": "#2A3040",
|
||||
"accent.foreground": "#C8D3E0",
|
||||
"background": "#1A1D23",
|
||||
"border": "#2E3340",
|
||||
"danger.background": "#E05555",
|
||||
"danger.active.background": "#CC3D3D",
|
||||
"danger.foreground": "#FFFFFF",
|
||||
"foreground": "#C8CED8",
|
||||
"input.border": "#3A4050",
|
||||
"link.active.foreground": "#6CB4EE",
|
||||
"link.foreground": "#6CB4EE",
|
||||
"link.hover.foreground": "#8FC8F6",
|
||||
"list.active.background": "#6CB4EE15",
|
||||
"list.hover.background": "#252A34",
|
||||
"list.active.border": "#6CB4EE",
|
||||
"list.even.background": "#1E2229",
|
||||
"muted.background": "#252A3466",
|
||||
"muted.foreground": "#6B7280",
|
||||
"panel.background": "#1E2229",
|
||||
"popover.background": "#1A1D23",
|
||||
"popover.foreground": "#C8CED8",
|
||||
"primary.active.background": "#4A90D9",
|
||||
"primary.background": "#5BA0E0",
|
||||
"primary.foreground": "#1A1D23",
|
||||
"primary.hover.background": "#6CB4EE",
|
||||
"ring": "#5BA0E0",
|
||||
"scrollbar.background": "#1A1D2300",
|
||||
"scrollbar.thumb.background": "#3A4050",
|
||||
"secondary.active.background": "#333A48",
|
||||
"secondary.background": "#252A34",
|
||||
"secondary.foreground": "#C8CED8",
|
||||
"secondary.hover.background": "#2E3542",
|
||||
"tab.active.background": "#1A1D23",
|
||||
"tab.active.foreground": "#C8CED8",
|
||||
"tab.background": "#252A3400",
|
||||
"tab.foreground": "#6B7280",
|
||||
"tab_bar.background": "#1E2229DD",
|
||||
"title_bar.background": "#1E2229",
|
||||
"title_bar.border": "#2E3340",
|
||||
"base.magenta.light": "#C792EA44",
|
||||
"base.red": "#E06060",
|
||||
"base.green": "#7EC699",
|
||||
"base.yellow": "#E8C97A",
|
||||
"base.blue": "#6CB4EE",
|
||||
"base.magenta": "#C792EA",
|
||||
"base.cyan": "#56D4C7"
|
||||
},
|
||||
"highlight": {
|
||||
"editor.foreground": "#C8CED8",
|
||||
"editor.background": "#1A1D23",
|
||||
"editor.active_line.background": "#222730",
|
||||
"editor.line_number": "#5A6170",
|
||||
"editor.active_line_number": "#C8CED8",
|
||||
"editor.invisible": "#5A617055",
|
||||
"conflict": "#E06060",
|
||||
"created": "#7EC699",
|
||||
"deleted": "#CC4444",
|
||||
"error": "#E06060",
|
||||
"hidden": "#6B7280",
|
||||
"hint": "#C792EA",
|
||||
"ignored": "#5A6170",
|
||||
"info": "#56D4C7",
|
||||
"modified": "#E8C97A",
|
||||
"predictive": "#4A5568",
|
||||
"renamed": "#E8C97A",
|
||||
"success": "#7EC699",
|
||||
"unreachable": "#5A6170",
|
||||
"warning": "#E8C97A",
|
||||
"syntax": {
|
||||
"attribute": {
|
||||
"color": "#C792EA"
|
||||
},
|
||||
"boolean": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"comment": {
|
||||
"color": "#5A6A7A",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"comment.doc": {
|
||||
"color": "#5A6A7A",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"constant": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"constructor": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"embedded": {
|
||||
"color": "#C8CED8"
|
||||
},
|
||||
"function": {
|
||||
"color": "#E2C08D"
|
||||
},
|
||||
"keyword": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"link_text": {
|
||||
"color": "#6CB4EE",
|
||||
"font_style": "normal"
|
||||
},
|
||||
"link_uri": {
|
||||
"color": "#56D4C7",
|
||||
"font_style": "italic"
|
||||
},
|
||||
"number": {
|
||||
"color": "#7EC699"
|
||||
},
|
||||
"string": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.escape": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.regex": {
|
||||
"color": "#E8A87C"
|
||||
},
|
||||
"string.special": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"string.special.symbol": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"tag": {
|
||||
"color": "#6CB4EE"
|
||||
},
|
||||
"text.literal": {
|
||||
"color": "#56D4C7"
|
||||
},
|
||||
"title": {
|
||||
"color": "#E2C08D",
|
||||
"font_weight": 600
|
||||
},
|
||||
"type": {
|
||||
"color": "#7EC699"
|
||||
},
|
||||
"property": {
|
||||
"color": "#C8CED8"
|
||||
},
|
||||
"variable.special": {
|
||||
"color": "#E06060"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
fn main() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set_icon("assets/icons/ashell.ico");
|
||||
res.compile().unwrap();
|
||||
}
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
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"
|
||||
download: "Download"
|
||||
download_folder: "Download Folder"
|
||||
download_count: "Download (%{count})"
|
||||
name: "NAME"
|
||||
size: "SIZE"
|
||||
modified: "MODIFIED"
|
||||
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"
|
||||
confirm_delete: "Confirm Delete"
|
||||
confirm_delete_desc: "Are you sure you want to delete the selected %{count} items? This action cannot be undone."
|
||||
system_path_warning: "WARNING: You are attempting to delete critical system paths! This may cause system corruption!"
|
||||
delete_success: "Successfully deleted %{count} items"
|
||||
delete_failed: "Failed to delete: %{err}"
|
||||
deleting_paths: "Deleting %{count} items..."
|
||||
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"
|
||||
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"
|
||||
light_theme: "Light Theme"
|
||||
dark_theme: "Dark Theme"
|
||||
use_light_mode: "Use Light Mode"
|
||||
use_dark_mode: "Use Dark Mode"
|
||||
english: "English"
|
||||
chinese: "简体中文"
|
||||
cpu: "CPU"
|
||||
mem: "MEM"
|
||||
swap: "SWAP"
|
||||
net: "NET"
|
||||
disk: "DISK"
|
||||
live: "Live"
|
||||
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"
|
||||
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}..."
|
||||
download_failed: "Download failed: %{err}"
|
||||
uploading: "Uploading..."
|
||||
upload_failed: "Upload failed: %{err}"
|
||||
downloaded_folder: "Downloaded folder to %{path}"
|
||||
downloaded_file: "Downloaded file to %{path}"
|
||||
uploaded_file: "Uploaded file"
|
||||
uploaded_folder: "Uploaded folder"
|
||||
uploaded_n_files: "Uploaded %{files} files"
|
||||
uploaded_n_folders: "Uploaded %{folders} folders"
|
||||
uploaded_files_and_folders: "Uploaded %{files} files and %{folders} folders"
|
||||
n_files: "%{files} files"
|
||||
n_folders: "%{folders} folders"
|
||||
n_files_and_folders: "%{files} files and %{folders} folders"
|
||||
reset: "Reset"
|
||||
reset_layout: "Reset Default Layout"
|
||||
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
|
||||
|
||||
transfers: "Transfers"
|
||||
no_transfers_yet: "No transfers yet"
|
||||
downloading: "Downloading"
|
||||
paused: "Paused"
|
||||
completed: "Completed"
|
||||
terminal_attention_required: "Terminal requires attention"
|
||||
failed: "Failed"
|
||||
interrupted: "Interrupted"
|
||||
session: "Session"
|
||||
zombie: "Zombie"
|
||||
zombie_reason: "This transfer was interrupted because the application exited. The transfer task no longer exists."
|
||||
|
||||
software_builtin: "Built-in"
|
||||
retry: "Retry"
|
||||
right_click_copy_paste: "Right-click Copy/Paste"
|
||||
keyword_highlight: "Terminal Keyword Highlighting"
|
||||
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: "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_mode: "Theme Mode"
|
||||
clear_transfers: "Clear History"
|
||||
transfers_limit: " (Max 100 records)"
|
||||
|
||||
press_new_key: "Press new key..."
|
||||
none: "None"
|
||||
settings_open_settings: "Settings"
|
||||
settings_open_session: "Open Session"
|
||||
settings_tab_list: "Tab List"
|
||||
settings_open_transfers: "Open Transfer History"
|
||||
panel_minimize: "Minimize Panel"
|
||||
panel_minimize_short: "Collapse"
|
||||
panel_expand: "Expand Panel"
|
||||
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 Panel Size"
|
||||
settings_focus_pane_left: "Focus Pane Left"
|
||||
settings_focus_pane_right: "Focus Pane Right"
|
||||
settings_focus_pane_up: "Focus Pane Up"
|
||||
settings_focus_pane_down: "Focus Pane Down"
|
||||
settings_split_pane_left: "Split Pane Left"
|
||||
settings_split_pane_right: "Split Pane Right"
|
||||
settings_split_pane_up: "Split Pane Up"
|
||||
settings_split_pane_down: "Split Pane Down"
|
||||
settings_close_pane: "Close Pane"
|
||||
keybind_conflict: "\"%{key}\" is already used by \"%{action}\", please choose another key"
|
||||
title_bar_style: "Title Bar Style"
|
||||
title_bar_native: "Native"
|
||||
title_bar_integrated: "Integrated"
|
||||
restart_hint: " (Restart required)"
|
||||
settings_sync: "Configuration Sync"
|
||||
sync_endpoint: "WebDAV Endpoint"
|
||||
sync_username: "WebDAV Username"
|
||||
sync_webdav_password: "WebDAV Password"
|
||||
sync_encryption_password: "Encryption Password (8+ characters)"
|
||||
sync_security_hint: "Only connection parameters are stored locally. WebDAV passwords, S3 secrets, and encryption passwords remain in memory for this process. Uploaded data is end-to-end encrypted. Downloading replaces locally saved SSH sessions."
|
||||
sync_s3_endpoint: "S3 Endpoint (optional for AWS S3)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "Object Key"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token (optional)"
|
||||
sync_download: "Download Remote"
|
||||
sync_upload: "Upload Remote"
|
||||
sync_not_run: "Not synchronized yet"
|
||||
sync_uploading: "Encrypting and uploading configuration..."
|
||||
sync_downloading: "Downloading and decrypting configuration..."
|
||||
sync_upload_complete: "Configuration uploaded"
|
||||
sync_download_complete: "Configuration downloaded"
|
||||
sync_failed: "Synchronization failed"
|
||||
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"
|
||||
@@ -0,0 +1,374 @@
|
||||
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: "显示隐藏文件"
|
||||
download: "下载"
|
||||
download_folder: "下载文件夹"
|
||||
download_count: "下载 (%{count})"
|
||||
name: "名称"
|
||||
size: "大小"
|
||||
modified: "修改时间"
|
||||
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: "删除"
|
||||
confirm_delete: "确认删除"
|
||||
confirm_delete_desc: "确定要删除以下选中的 %{count} 个项吗?此操作无法撤销。"
|
||||
system_path_warning: "警告:您即将删除系统关键路径!这可能会导致系统损坏!"
|
||||
delete_success: "成功删除 %{count} 个项"
|
||||
delete_failed: "删除失败: %{err}"
|
||||
deleting_paths: "正在删除 %{count} 个项..."
|
||||
confirm: "确认"
|
||||
local_terminal: "本地终端"
|
||||
open_local_shell_tab: "打开一个新的本地终端标签页"
|
||||
connect: "连接"
|
||||
edit: "编辑"
|
||||
done: "完成"
|
||||
delete: "删除"
|
||||
host: "主机 (Host)"
|
||||
port: "端口 (Port)"
|
||||
user: "用户 (User)"
|
||||
password: "密码 (Password)"
|
||||
key: "密钥 (Key)"
|
||||
ssh_config: "配置(~/.ssh/config)"
|
||||
ssh_config_empty: "~/.ssh/config 中未找到条目"
|
||||
save: "保存"
|
||||
cancel: "取消"
|
||||
more: "更多"
|
||||
host_and_user_required: "主机名和用户名不能为空"
|
||||
language: "显示语言"
|
||||
follow_system: "跟随系统"
|
||||
light_theme: "浅色主题"
|
||||
dark_theme: "深色主题"
|
||||
use_light_mode: "使用浅色模式"
|
||||
use_dark_mode: "使用深色模式"
|
||||
english: "English"
|
||||
chinese: "简体中文"
|
||||
cpu: "CPU"
|
||||
mem: "内存"
|
||||
swap: "虚拟内存"
|
||||
net: "网络"
|
||||
disk: "磁盘"
|
||||
live: "实时"
|
||||
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: "终端字体"
|
||||
cursor_style: "光标样式"
|
||||
cursor_style_default: "默认"
|
||||
cursor_style_blink: "闪烁"
|
||||
cursor_style_beam: "细线"
|
||||
cursor_style_beam_blink: "细线闪烁"
|
||||
monitoring_position: "监控显示位置"
|
||||
position_bottom: "底部"
|
||||
position_sidebar: "侧边栏"
|
||||
position_hidden: "不显示"
|
||||
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}..."
|
||||
download_failed: "下载失败: %{err}"
|
||||
uploading: "正在上传..."
|
||||
upload_failed: "上传失败: %{err}"
|
||||
downloaded_folder: "已下载文件夹至 %{path}"
|
||||
downloaded_file: "已下载文件至 %{path}"
|
||||
uploaded_file: "已上传文件"
|
||||
uploaded_folder: "已上传文件夹"
|
||||
uploaded_n_files: "已上传 %{files} 个文件"
|
||||
uploaded_n_folders: "已上传 %{folders} 个文件夹"
|
||||
uploaded_files_and_folders: "已上传 %{files} 个文件及 %{folders} 个文件夹"
|
||||
n_files: "%{files} 个文件"
|
||||
n_folders: "%{folders} 个文件夹"
|
||||
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
|
||||
reset: "重置"
|
||||
reset_layout: "恢复默认布局"
|
||||
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
|
||||
|
||||
transfers: "传输记录"
|
||||
no_transfers_yet: "暂无传输记录"
|
||||
downloading: "正在下载"
|
||||
paused: "已暂停"
|
||||
completed: "已完成"
|
||||
terminal_attention_required: "终端中有需要关注的新状态"
|
||||
failed: "失败"
|
||||
interrupted: "已中断"
|
||||
session: "会话"
|
||||
zombie: "僵尸任务"
|
||||
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
|
||||
|
||||
software_builtin: "软件内置"
|
||||
retry: "重试"
|
||||
right_click_copy_paste: "右键复制/粘贴"
|
||||
keyword_highlight: "终端关键词高亮"
|
||||
remember_tabs: "记忆标签页"
|
||||
lock_layout: "锁定窗口布局"
|
||||
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
|
||||
copy_paste_hint: "选中后左键点击也可复制。"
|
||||
settings_copy: "复制选区"
|
||||
settings_paste: "粘贴剪贴板"
|
||||
edit_file: "编辑文件"
|
||||
edit_file_tooltip: "直接在 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_mode: "主题模式"
|
||||
clear_transfers: "清空记录"
|
||||
transfers_limit: " (最多保留 100 条)"
|
||||
|
||||
press_new_key: "请按下新的快捷键..."
|
||||
none: "无"
|
||||
settings_open_settings: "设置"
|
||||
settings_open_session: "打开会话"
|
||||
settings_tab_list: "标签列表"
|
||||
settings_open_transfers: "打开传输历史"
|
||||
panel_minimize: "折叠面板"
|
||||
panel_minimize_short: "折叠"
|
||||
panel_expand: "展开面板"
|
||||
panel_expand_short: "展开"
|
||||
settings_new_connection: "新建连接"
|
||||
settings_open_search: "搜索"
|
||||
settings_quit_application: "退出应用"
|
||||
search: "搜索"
|
||||
no_results: "无结果"
|
||||
settings_toggle_sidebar: "切换侧边栏"
|
||||
settings_toggle_sftp_zoom: "切换 SFTP 面板大小"
|
||||
settings_focus_pane_left: "向左切换焦点"
|
||||
settings_focus_pane_right: "向右切换焦点"
|
||||
settings_focus_pane_up: "向上切换焦点"
|
||||
settings_focus_pane_down: "向下切换焦点"
|
||||
settings_split_pane_left: "向左拆分面板"
|
||||
settings_split_pane_right: "向右拆分面板"
|
||||
settings_split_pane_up: "向上拆分面板"
|
||||
settings_split_pane_down: "向下拆分面板"
|
||||
settings_close_pane: "关闭当前面板"
|
||||
keybind_conflict: "\"%{key}\" 已被「%{action}」使用,请选择其他快捷键"
|
||||
title_bar_style: "标题栏样式"
|
||||
title_bar_native: "原生"
|
||||
title_bar_integrated: "融合"
|
||||
restart_hint: " (修改后重启生效)"
|
||||
settings_sync: "配置同步"
|
||||
sync_endpoint: "WebDAV 地址"
|
||||
sync_username: "WebDAV 用户名"
|
||||
sync_webdav_password: "WebDAV 密码"
|
||||
sync_encryption_password: "配置加密口令(至少 8 位)"
|
||||
sync_security_hint: "仅连接参数会保存到本机。WebDAV 密码、S3 密钥和加密口令只保留在当前进程内;上传内容使用端到端加密。下载会替换本机保存的 SSH 会话。"
|
||||
sync_s3_endpoint: "S3 Endpoint(AWS S3 可留空)"
|
||||
sync_s3_region: "S3 Region"
|
||||
sync_s3_bucket: "S3 Bucket"
|
||||
sync_s3_object_key: "对象路径"
|
||||
sync_s3_access_key: "Access Key ID"
|
||||
sync_s3_secret_key: "Secret Access Key"
|
||||
sync_s3_session_token: "Session Token(可选)"
|
||||
sync_download: "从远程下载"
|
||||
sync_upload: "上传到远程"
|
||||
sync_not_run: "尚未同步"
|
||||
sync_uploading: "正在加密并上传配置..."
|
||||
sync_downloading: "正在下载并解密配置..."
|
||||
sync_upload_complete: "配置上传完成"
|
||||
sync_download_complete: "配置下载完成"
|
||||
sync_failed: "同步失败"
|
||||
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()
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 279 KiB |
@@ -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
|
||||
@@ -17,6 +24,8 @@ rm -rf "$APP_DIR"
|
||||
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp "$ROOT_DIR/target/release/$APP_NAME" "$MACOS_DIR/$APP_NAME"
|
||||
|
||||
cp "$ROOT_DIR/assets/icons/ashell.icns" "$RESOURCES_DIR/ashell.icns"
|
||||
|
||||
cat > "$CONTENTS_DIR/Info.plist" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
@@ -27,6 +36,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>ashell.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$BUNDLE_ID</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
@@ -36,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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
pub(crate) const DEFAULT_COLS: u16 = 100;
|
||||
pub(crate) const DEFAULT_ROWS: u16 = 30;
|
||||
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,
|
||||
}
|
||||
+4487
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,480 @@
|
||||
use gpui::{
|
||||
Action as _, App, Entity, IntoElement, KeyBinding, KeyDownEvent, Keystroke, Unbind, prelude::*,
|
||||
};
|
||||
use gpui_component::{
|
||||
Sizable,
|
||||
button::ButtonVariants,
|
||||
kbd::Kbd,
|
||||
setting::{SettingField, SettingGroup, SettingItem},
|
||||
};
|
||||
use rust_i18n::t;
|
||||
|
||||
use crate::{Ashell, app::controls::pointer_button, session::config::ConfigStore};
|
||||
|
||||
gpui::actions!(
|
||||
ashell_workspace,
|
||||
[
|
||||
OpenSettings,
|
||||
OpenSession,
|
||||
OpenTransfers,
|
||||
NewSsh,
|
||||
OpenSearch,
|
||||
QuitApplication,
|
||||
ToggleSidebar,
|
||||
ToggleSftpZoom,
|
||||
FocusPaneLeft,
|
||||
FocusPaneRight,
|
||||
FocusPaneUp,
|
||||
FocusPaneDown,
|
||||
SplitPaneLeft,
|
||||
SplitPaneRight,
|
||||
SplitPaneUp,
|
||||
SplitPaneDown,
|
||||
ClosePane,
|
||||
Copy,
|
||||
Paste
|
||||
]
|
||||
);
|
||||
|
||||
pub struct KeybindingsPage;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct WorkspaceAction {
|
||||
id: &'static str,
|
||||
label_key: &'static str,
|
||||
default_suffix: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) const WORKSPACE_ACTIONS: &[WorkspaceAction] = &[
|
||||
WorkspaceAction {
|
||||
id: "OpenSettings",
|
||||
label_key: "settings_open_settings",
|
||||
default_suffix: ",",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "OpenSession",
|
||||
label_key: "settings_open_session",
|
||||
default_suffix: "o",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "OpenTransfers",
|
||||
label_key: "settings_open_transfers",
|
||||
default_suffix: "t",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "NewSsh",
|
||||
label_key: "settings_new_connection",
|
||||
default_suffix: "n",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "OpenSearch",
|
||||
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",
|
||||
default_suffix: "s",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "ToggleSftpZoom",
|
||||
label_key: "settings_toggle_sftp_zoom",
|
||||
default_suffix: "m",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneLeft",
|
||||
label_key: "settings_focus_pane_left",
|
||||
default_suffix: "h",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneRight",
|
||||
label_key: "settings_focus_pane_right",
|
||||
default_suffix: "l",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneUp",
|
||||
label_key: "settings_focus_pane_up",
|
||||
default_suffix: "k",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "FocusPaneDown",
|
||||
label_key: "settings_focus_pane_down",
|
||||
default_suffix: "j",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneLeft",
|
||||
label_key: "settings_split_pane_left",
|
||||
default_suffix: "shift-h",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneRight",
|
||||
label_key: "settings_split_pane_right",
|
||||
default_suffix: "shift-l",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneUp",
|
||||
label_key: "settings_split_pane_up",
|
||||
default_suffix: "shift-k",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "SplitPaneDown",
|
||||
label_key: "settings_split_pane_down",
|
||||
default_suffix: "shift-j",
|
||||
},
|
||||
WorkspaceAction {
|
||||
id: "ClosePane",
|
||||
label_key: "settings_close_pane",
|
||||
default_suffix: "w",
|
||||
},
|
||||
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 {
|
||||
if cfg!(target_os = "macos") {
|
||||
"cmd"
|
||||
} else {
|
||||
"ctrl"
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
.map(|action| format!("{}-{}", default_modifier(), action.default_suffix))
|
||||
}
|
||||
|
||||
pub(crate) fn configured_keystroke(config: &ConfigStore, action_id: &str) -> Option<String> {
|
||||
config
|
||||
.key_bindings()
|
||||
.get(action_id)
|
||||
.cloned()
|
||||
.or_else(|| default_keystroke(action_id))
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_recorded_keystroke(event: &KeyDownEvent) -> Option<String> {
|
||||
let key = event.keystroke.key.trim();
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if event.keystroke.modifiers.control {
|
||||
parts.push("ctrl".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.alt {
|
||||
parts.push("alt".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.shift {
|
||||
parts.push("shift".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.platform {
|
||||
parts.push("cmd".to_string());
|
||||
}
|
||||
if event.keystroke.modifiers.function {
|
||||
parts.push("fn".to_string());
|
||||
}
|
||||
|
||||
parts.push(key.to_ascii_lowercase());
|
||||
let keystroke = parts.join("-");
|
||||
Keystroke::parse(&keystroke).ok().map(|_| keystroke)
|
||||
}
|
||||
|
||||
pub(crate) fn format_keystroke(keystroke: &str) -> String {
|
||||
Keystroke::parse(keystroke)
|
||||
.map(|stroke| Kbd::format(&stroke))
|
||||
.unwrap_or_else(|_| keystroke.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn bind_workspace_keys_from_config(cx: &mut App, config: &ConfigStore) {
|
||||
bind_workspace_actions(cx, config);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
macro_rules! unbind_action {
|
||||
($id:literal, $action:expr) => {
|
||||
let default = default_keystroke($id).expect("workspace action has default key");
|
||||
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
|
||||
let action_name = $action.name();
|
||||
|
||||
// Unbind both the default and configured keystroke
|
||||
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
|
||||
if configured != default && configured != "none" && !configured.is_empty() {
|
||||
bindings.push(KeyBinding::new(
|
||||
&configured,
|
||||
Unbind(action_name.into()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
unbind_action!("OpenSettings", crate::OpenSettings);
|
||||
unbind_action!("OpenSession", crate::OpenSession);
|
||||
unbind_action!("OpenTransfers", crate::OpenTransfers);
|
||||
unbind_action!("NewSsh", crate::NewSsh);
|
||||
unbind_action!("OpenSearch", crate::OpenSearch);
|
||||
unbind_action!("ToggleSidebar", crate::ToggleSidebar);
|
||||
unbind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
|
||||
unbind_action!("FocusPaneLeft", crate::FocusPaneLeft);
|
||||
unbind_action!("FocusPaneRight", crate::FocusPaneRight);
|
||||
unbind_action!("FocusPaneUp", crate::FocusPaneUp);
|
||||
unbind_action!("FocusPaneDown", crate::FocusPaneDown);
|
||||
unbind_action!("SplitPaneLeft", crate::SplitPaneLeft);
|
||||
unbind_action!("SplitPaneRight", crate::SplitPaneRight);
|
||||
unbind_action!("SplitPaneUp", crate::SplitPaneUp);
|
||||
unbind_action!("SplitPaneDown", crate::SplitPaneDown);
|
||||
unbind_action!("ClosePane", crate::ClosePane);
|
||||
unbind_action!("Copy", crate::Copy);
|
||||
unbind_action!("Paste", crate::Paste);
|
||||
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
/// Check if a keystroke conflicts with any other action's binding.
|
||||
/// Returns Some((conflicting_action_id, label)) if there is a conflict.
|
||||
pub(crate) fn find_conflict(
|
||||
config: &ConfigStore,
|
||||
current_action_id: &str,
|
||||
new_keystroke: &str,
|
||||
) -> Option<(String, String)> {
|
||||
for action in WORKSPACE_ACTIONS {
|
||||
if action.id == current_action_id {
|
||||
continue;
|
||||
}
|
||||
let existing = configured_keystroke(config, action.id).unwrap_or_default();
|
||||
if !existing.is_empty() && existing != "none" && existing == new_keystroke {
|
||||
return Some((action.id.to_string(), t!(action.label_key).to_string()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bind_workspace_actions(cx: &mut App, config: &ConfigStore) {
|
||||
let mut bindings = Vec::new();
|
||||
|
||||
macro_rules! bind_action {
|
||||
($id:literal, $action:expr) => {
|
||||
let default = default_keystroke($id).expect("workspace action has default key");
|
||||
let configured = configured_keystroke(config, $id).unwrap_or_else(|| default.clone());
|
||||
let action_name = $action.name();
|
||||
|
||||
if configured != default {
|
||||
bindings.push(KeyBinding::new(&default, Unbind(action_name.into()), None));
|
||||
}
|
||||
|
||||
if configured != "none" && !configured.is_empty() {
|
||||
bindings.push(KeyBinding::new(&configured, $action, None));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
bind_action!("OpenSettings", crate::OpenSettings);
|
||||
bind_action!("OpenSession", crate::OpenSession);
|
||||
bind_action!("OpenTransfers", crate::OpenTransfers);
|
||||
bind_action!("NewSsh", crate::NewSsh);
|
||||
bind_action!("OpenSearch", crate::OpenSearch);
|
||||
bind_action!("QuitApplication", crate::QuitApplication);
|
||||
bind_action!("ToggleSidebar", crate::ToggleSidebar);
|
||||
bind_action!("ToggleSftpZoom", crate::ToggleSftpZoom);
|
||||
bind_action!("FocusPaneLeft", crate::FocusPaneLeft);
|
||||
bind_action!("FocusPaneRight", crate::FocusPaneRight);
|
||||
bind_action!("FocusPaneUp", crate::FocusPaneUp);
|
||||
bind_action!("FocusPaneDown", crate::FocusPaneDown);
|
||||
bind_action!("SplitPaneLeft", crate::SplitPaneLeft);
|
||||
bind_action!("SplitPaneRight", crate::SplitPaneRight);
|
||||
bind_action!("SplitPaneUp", crate::SplitPaneUp);
|
||||
bind_action!("SplitPaneDown", crate::SplitPaneDown);
|
||||
bind_action!("ClosePane", crate::ClosePane);
|
||||
bind_action!("Copy", crate::Copy);
|
||||
bind_action!("Paste", crate::Paste);
|
||||
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
impl KeybindingsPage {
|
||||
pub fn render_groups(view: &Entity<Ashell>, cx: &mut App) -> Vec<SettingGroup> {
|
||||
let groups = [
|
||||
(
|
||||
"settings_group_keybind_general",
|
||||
vec![
|
||||
"OpenSettings",
|
||||
"OpenSession",
|
||||
"OpenTransfers",
|
||||
"NewSsh",
|
||||
"OpenSearch",
|
||||
"QuitApplication",
|
||||
"Copy",
|
||||
"Paste",
|
||||
],
|
||||
),
|
||||
(
|
||||
"settings_group_keybind_zoom",
|
||||
vec!["ToggleSidebar", "ToggleSftpZoom"],
|
||||
),
|
||||
(
|
||||
"settings_group_keybind_focus",
|
||||
vec![
|
||||
"FocusPaneLeft",
|
||||
"FocusPaneRight",
|
||||
"FocusPaneUp",
|
||||
"FocusPaneDown",
|
||||
],
|
||||
),
|
||||
(
|
||||
"settings_group_keybind_panel",
|
||||
vec![
|
||||
"SplitPaneLeft",
|
||||
"SplitPaneRight",
|
||||
"SplitPaneUp",
|
||||
"SplitPaneDown",
|
||||
"ClosePane",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for (group_label, action_ids) in groups {
|
||||
let mut group = SettingGroup::new().title(t!(group_label).to_string());
|
||||
|
||||
for action_id in action_ids {
|
||||
let action = WORKSPACE_ACTIONS
|
||||
.iter()
|
||||
.find(|a| a.id == action_id)
|
||||
.expect("action exists");
|
||||
|
||||
let recording = view.read(cx).recording_action.as_deref() == Some(action.id);
|
||||
let has_error = view
|
||||
.read(cx)
|
||||
.keybind_error
|
||||
.as_ref()
|
||||
.is_some_and(|(id, _)| id == action.id);
|
||||
let error_msg = if has_error {
|
||||
view.read(cx)
|
||||
.keybind_error
|
||||
.as_ref()
|
||||
.map(|(_, msg)| msg.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let keystroke = {
|
||||
let config = &view.read(cx).config;
|
||||
configured_keystroke(config, action.id).unwrap_or_default()
|
||||
};
|
||||
|
||||
let btn_label = if recording {
|
||||
t!("press_new_key").to_string()
|
||||
} else if keystroke.is_empty() {
|
||||
t!("none").to_string()
|
||||
} else {
|
||||
format_keystroke(&keystroke)
|
||||
};
|
||||
|
||||
let mut item = SettingItem::new(
|
||||
t!(action.label_key).to_string(),
|
||||
SettingField::render({
|
||||
let view = view.clone();
|
||||
let action_id = action.id.to_string();
|
||||
move |_, _window, _cx| {
|
||||
pointer_button(gpui::SharedString::from(format!("keybind-{action_id}")))
|
||||
.label(btn_label.clone())
|
||||
.small()
|
||||
.when(recording, |this| this.primary())
|
||||
.when(has_error, |this| this.danger())
|
||||
.on_click({
|
||||
let view = view.clone();
|
||||
let action_id = action_id.clone();
|
||||
move |_event, window, cx| {
|
||||
view.update(cx, |this, cx| {
|
||||
// Clear any previous error when starting new recording
|
||||
this.keybind_error = None;
|
||||
suspend_quit_keybinding(cx, &this.config);
|
||||
this.recording_action = Some(action_id.clone());
|
||||
this.focus_handle.focus(window, cx);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if let Some(msg) = error_msg {
|
||||
item = item.description(msg);
|
||||
}
|
||||
|
||||
group = group.item(item);
|
||||
}
|
||||
|
||||
result.push(group);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
+2373
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{
|
||||
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::ButtonVariants as _, h_flex, input::Input,
|
||||
};
|
||||
use rust_i18n::t;
|
||||
|
||||
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>) {
|
||||
if self.search_active {
|
||||
self.close_search(window, cx);
|
||||
} else {
|
||||
self.open_search(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.search_active = true;
|
||||
// Focus the search input on the next frame so it happens after the
|
||||
// current render cycle completes, avoiding focus being stolen back
|
||||
// by the terminal panel's track_focus.
|
||||
let search_input = self.search_input.clone();
|
||||
cx.on_next_frame(window, move |_this, window, cx| {
|
||||
search_input.update(cx, |state, cx| {
|
||||
state.focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.search_active = false;
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.search_target_tab = None;
|
||||
self.search_bar_bounds = None;
|
||||
self.focus_handle.focus(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Move keyboard focus back to the search input so the user can keep typing.
|
||||
/// Deferred to the next frame so it happens after the current render cycle,
|
||||
/// preventing the terminal panel's track_focus from stealing focus back.
|
||||
fn refocus_search_input(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let search_input = self.search_input.clone();
|
||||
cx.on_next_frame(window, move |_this, window, cx| {
|
||||
search_input.update(cx, |state, cx| {
|
||||
state.focus_handle(cx).focus(window, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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() {
|
||||
self.search_query.clear();
|
||||
self.search_matches.clear();
|
||||
self.search_current = 0;
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(tab_index) = self.search_tab_index() else {
|
||||
self.status = t!("no_results").into();
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
// Remember which tab was searched so highlights only appear in that pane.
|
||||
self.search_target_tab = Some(self.tabs[tab_index].id.clone());
|
||||
let tab = &self.tabs[tab_index];
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_byte_len = query.len();
|
||||
|
||||
// Search the ENTIRE terminal buffer (scrollback + visible screen).
|
||||
// full_grid_rows returns grid line indices; the first row is at
|
||||
// `grid_start` (typically -history_size).
|
||||
let (grid_start, all_rows) = tab.full_grid_rows();
|
||||
|
||||
let mut matches: Vec<(i32, i32)> = Vec::new();
|
||||
|
||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||
if row.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build text string and byte→column index mapping.
|
||||
let mut text = String::with_capacity(row.len());
|
||||
let mut byte_to_col: Vec<i32> = Vec::new();
|
||||
for &(col, c) in row {
|
||||
text.push(c);
|
||||
while byte_to_col.len() < text.len() {
|
||||
byte_to_col.push(col);
|
||||
}
|
||||
}
|
||||
let text_lower = text.to_lowercase();
|
||||
|
||||
// Grid line index for this row.
|
||||
let abs_row = grid_start + row_idx as i32;
|
||||
|
||||
let mut search_start = 0;
|
||||
while let Some(pos) = text_lower[search_start..].find(&query_lower) {
|
||||
let abs = search_start + pos;
|
||||
let start_col = byte_to_col[abs];
|
||||
let end_byte = (abs + query_byte_len).min(byte_to_col.len());
|
||||
let end_col = byte_to_col[end_byte - 1];
|
||||
for c in start_col..=end_col {
|
||||
matches.push((abs_row, c));
|
||||
}
|
||||
search_start = abs + query_byte_len;
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_unstable();
|
||||
let match_count = count_match_groups(&matches);
|
||||
|
||||
self.search_query = query;
|
||||
self.search_matches = matches;
|
||||
|
||||
if match_count > 0 {
|
||||
self.search_current = 0;
|
||||
self.jump_to_current_match(cx);
|
||||
}
|
||||
|
||||
self.status = format!(
|
||||
"{}: {} ({})",
|
||||
t!("search"),
|
||||
self.search_query,
|
||||
if match_count == 0 {
|
||||
t!("no_results").to_string()
|
||||
} else {
|
||||
format!("{}/{}", self.search_current + 1, match_count)
|
||||
}
|
||||
)
|
||||
.into();
|
||||
|
||||
// Keep focus on the search input so the user can continue typing.
|
||||
self.refocus_search_input(window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn search_goto_next(&mut self, cx: &mut Context<Self>) {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
if match_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.search_current = (self.search_current + 1) % match_count;
|
||||
self.jump_to_current_match(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn search_goto_prev(&mut self, cx: &mut Context<Self>) {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
if match_count == 0 {
|
||||
return;
|
||||
}
|
||||
self.search_current = (self.search_current + match_count - 1) % match_count;
|
||||
self.jump_to_current_match(cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn jump_to_current_match(&mut self, _cx: &mut Context<Self>) {
|
||||
// target_grid_line is the grid line index (negative = history).
|
||||
let Some((target_grid_line, _)) =
|
||||
find_nth_match_start(&self.search_matches, self.search_current)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the tab ID first (immutable borrow), then look up mutably.
|
||||
let tab_id = self.active_tab.clone().or_else(|| {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|gid| self.tab_groups.iter().find(|g| &g.id == gid))
|
||||
.and_then(|g| g.pane_root.first_tab_id())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let tab = if let Some(id) = tab_id.as_deref() {
|
||||
self.tabs.iter_mut().find(|t| t.id == id)
|
||||
} else {
|
||||
self.tabs.first_mut()
|
||||
};
|
||||
|
||||
if let Some(tab) = tab {
|
||||
let snapshot = tab.render_snapshot(false);
|
||||
let display_offset = snapshot.display_offset as i32;
|
||||
let rows = snapshot.rows as i32;
|
||||
|
||||
// viewport row = grid_line + display_offset
|
||||
let vp_row = target_grid_line + display_offset;
|
||||
let visible = vp_row >= 0 && vp_row < rows;
|
||||
|
||||
if !visible {
|
||||
// Scroll so the target grid line appears near the top.
|
||||
// display_offset = -grid_line puts it at viewport row 0.
|
||||
let new_offset = (-target_grid_line).max(0) as usize;
|
||||
if new_offset > snapshot.display_offset {
|
||||
tab.scroll_up_by(new_offset - snapshot.display_offset);
|
||||
} else if new_offset < snapshot.display_offset {
|
||||
tab.scroll_down_by(snapshot.display_offset - new_offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a highlight map for search matches, converting grid line indices
|
||||
/// to the current viewport coordinates. Only returns highlights for the
|
||||
/// pane that was actually searched (`search_target_tab`).
|
||||
pub(crate) fn search_highlight_map(
|
||||
&self,
|
||||
tab_id: &str,
|
||||
match_color: Hsla,
|
||||
current_color: Hsla,
|
||||
) -> Option<HashMap<(i32, i32), Hsla>> {
|
||||
if self.search_matches.is_empty()
|
||||
|| self.search_query.is_empty()
|
||||
|| self.search_target_tab.as_deref() != Some(tab_id)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
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 sorted = &self.search_matches;
|
||||
|
||||
let mut group_idx = 0;
|
||||
let mut i = 0;
|
||||
while i < sorted.len() {
|
||||
let is_current = group_idx == self.search_current;
|
||||
let color = if is_current {
|
||||
current_color
|
||||
} else {
|
||||
match_color
|
||||
};
|
||||
|
||||
// grid_line → viewport row: vp_row = grid_line + display_offset
|
||||
let (grid_line, _) = sorted[i];
|
||||
let vp_row = grid_line + display_offset;
|
||||
let next_i = next_match_group_index(sorted, i);
|
||||
if vp_row >= 0 && vp_row < rows {
|
||||
for &(_, col) in &sorted[i..next_i] {
|
||||
map.insert((vp_row, col), color);
|
||||
}
|
||||
}
|
||||
|
||||
group_idx += 1;
|
||||
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 {
|
||||
// Wrap in a div so .hover() doesn't conflict with Button's internal hover.
|
||||
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).
|
||||
pub(crate) fn render_search_bar(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
let match_count = count_match_groups(&self.search_matches);
|
||||
let has_query = !self.search_query.is_empty();
|
||||
let has_matches = match_count > 0;
|
||||
let current_display = if has_matches {
|
||||
format!("{}/{}", self.search_current + 1, match_count)
|
||||
} else if has_query {
|
||||
"0".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let view = cx.entity();
|
||||
div()
|
||||
.absolute()
|
||||
.top(px(8.))
|
||||
.right(px(24.))
|
||||
.on_prepaint(move |bounds, _window, cx| {
|
||||
view.update(cx, |this, _| {
|
||||
this.search_bar_bounds = Some(bounds);
|
||||
});
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|this, _, window, cx| {
|
||||
this.refocus_search_input(window, cx);
|
||||
cx.stop_propagation();
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.p_1()
|
||||
.rounded(px(6.))
|
||||
.bg(cx.theme().popover)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(
|
||||
div()
|
||||
.w(px(200.))
|
||||
.on_key_down(cx.listener(
|
||||
|this, event: &gpui::KeyDownEvent, window, cx| {
|
||||
if event.keystroke.key.as_str() == "escape" {
|
||||
this.close_search(window, cx);
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
}
|
||||
},
|
||||
))
|
||||
.child(Input::new(&self.search_input)),
|
||||
)
|
||||
.when(!current_display.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_size(ui_rems(0.75))
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.min_w(px(36.))
|
||||
.text_center()
|
||||
.child(current_display),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
pointer_button("search-prev")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::ChevronUp)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.search_goto_prev(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
pointer_button("search-next")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::ChevronDown)
|
||||
.disabled(!has_matches)
|
||||
.on_click(cx.listener(|this, _, _, cx| {
|
||||
this.search_goto_next(cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
pointer_button("search-close")
|
||||
.ghost()
|
||||
.small()
|
||||
.icon(IconName::Close)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.close_search(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Count distinct match groups in a sorted list of (row, col) positions.
|
||||
/// A group is a run of consecutive columns in the same row.
|
||||
fn count_match_groups(matches: &[(i32, i32)]) -> usize {
|
||||
let mut count = 0;
|
||||
let mut i = 0;
|
||||
while i < matches.len() {
|
||||
count += 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)> {
|
||||
let mut group_idx = 0;
|
||||
let mut i = 0;
|
||||
while i < matches.len() {
|
||||
if group_idx == n {
|
||||
return Some(matches[i]);
|
||||
}
|
||||
group_idx += 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
|
||||
use gpui_component::Root;
|
||||
|
||||
use crate::Ashell;
|
||||
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());
|
||||
crate::app::keybinding_recorder::bind_workspace_keys_from_config(cx, &config);
|
||||
}
|
||||
|
||||
struct LocalMinutelyRoller {
|
||||
dir: std::path::PathBuf,
|
||||
prefix: String,
|
||||
current_minute: u32,
|
||||
file: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl LocalMinutelyRoller {
|
||||
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
|
||||
Self {
|
||||
dir,
|
||||
prefix,
|
||||
current_minute: 60,
|
||||
file: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
|
||||
use chrono::Timelike;
|
||||
let minute = now.minute();
|
||||
if self.current_minute != minute || self.file.is_none() {
|
||||
let filename = format!("{}-{}.log", self.prefix, now.format("%Y-%m-%d-%H-%M"));
|
||||
let path = self.dir.join(filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
self.file = Some(file);
|
||||
self.current_minute = minute;
|
||||
|
||||
// Cleanup old files keeping last 6
|
||||
if let Ok(entries) = std::fs::read_dir(&self.dir) {
|
||||
let mut files: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
|
||||
.collect();
|
||||
files.sort_by_key(|e| {
|
||||
e.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||
});
|
||||
if files.len() > 6 {
|
||||
for file in files.iter().take(files.len() - 6) {
|
||||
let _ = std::fs::remove_file(file.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for LocalMinutelyRoller {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let now = chrono::Local::now();
|
||||
let _ = self.rollover(now);
|
||||
if let Some(f) = &mut self.file {
|
||||
f.write(buf)
|
||||
} else {
|
||||
Ok(buf.len())
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
if let Some(f) = &mut self.file {
|
||||
f.flush()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn init_logging() {
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
let log_dir = directories::BaseDirs::new()
|
||||
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
|
||||
std::fs::create_dir_all(&log_dir).ok();
|
||||
|
||||
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
|
||||
|
||||
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
|
||||
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
|
||||
std::mem::forget(_guard);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
let stdout_layer = if cfg!(debug_assertions) {
|
||||
Some(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||
.with_target(true),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(env_filter)
|
||||
.with(stdout_layer)
|
||||
.with(file_layer)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn sync_macos_launch_environment() {
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
||||
let Ok(output) = std::process::Command::new(&shell)
|
||||
.args(["-l", "-c", "env -0"])
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in output.stdout.split(|b| *b == 0) {
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(eq) = entry.iter().position(|b| *b == b'=') else {
|
||||
continue;
|
||||
};
|
||||
let Ok(key) = std::str::from_utf8(&entry[..eq]) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = std::str::from_utf8(&entry[eq + 1..]) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let should_import = matches!(
|
||||
key,
|
||||
"PATH"
|
||||
| "MANPATH"
|
||||
| "INFOPATH"
|
||||
| "LANG"
|
||||
| "LC_ALL"
|
||||
| "LC_CTYPE"
|
||||
| "SHELL"
|
||||
| "HOME"
|
||||
| "HOMEBREW_PREFIX"
|
||||
| "HOMEBREW_CELLAR"
|
||||
| "HOMEBREW_REPOSITORY"
|
||||
| "HTTP_PROXY"
|
||||
| "HTTPS_PROXY"
|
||||
| "ALL_PROXY"
|
||||
| "http_proxy"
|
||||
| "https_proxy"
|
||||
| "all_proxy"
|
||||
) || key.starts_with("LC_");
|
||||
|
||||
if should_import {
|
||||
unsafe {
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
window_options.titlebar = Some(gpui::TitlebarOptions {
|
||||
title: None,
|
||||
appears_transparent: true,
|
||||
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
|
||||
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
|
||||
}
|
||||
|
||||
if let Some(bounds) = config.window_bounds() {
|
||||
window_options.window_bounds = Some(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;
|
||||
let height = display_bounds.size.height * 0.9;
|
||||
|
||||
let x = display_bounds.origin.x + (display_bounds.size.width - width) / 2.0;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let y = display_bounds.origin.y;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let y = display_bounds.origin.y + (display_bounds.size.height - height) / 2.0;
|
||||
|
||||
window_options.window_bounds = Some(gpui::WindowBounds::Windowed(Bounds::new(
|
||||
point(x, y),
|
||||
size(width, height),
|
||||
)));
|
||||
}
|
||||
|
||||
cx.open_window(window_options, |window, cx| {
|
||||
window.activate_window();
|
||||
window.set_window_title("ashell");
|
||||
gpui_component::Theme::sync_system_appearance(Some(window), cx);
|
||||
let view = cx.new(|cx| Ashell::new(window, cx));
|
||||
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."
|
||||
);
|
||||
return true;
|
||||
}
|
||||
view_clone.update(cx, |this, cx| {
|
||||
this.save_layout_state(window, cx);
|
||||
});
|
||||
true
|
||||
});
|
||||
|
||||
cx.new(|cx| Root::new(view, window, cx))
|
||||
})
|
||||
.expect("failed to open window");
|
||||
}
|
||||
@@ -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,
|
||||
)]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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;
|
||||
|
||||
pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
|
||||
include_str!("../../assets/themes/matrix.json"),
|
||||
include_str!("../../assets/themes/tokyonight.json"),
|
||||
include_str!("../../assets/themes/gruvbox.json"),
|
||||
include_str!("../../assets/themes/solarized.json"),
|
||||
include_str!("../../assets/themes/phygerr.json"),
|
||||
];
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
pub(crate) static USING_SYSTEM_MAPLE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub(crate) const COMMON_SYSTEM_UI_FONTS: &[&str] = &[
|
||||
"Helvetica",
|
||||
"Segoe UI",
|
||||
"Ubuntu",
|
||||
"Adwaita Sans",
|
||||
"Cantarell",
|
||||
"Noto Sans",
|
||||
"DejaVu Sans",
|
||||
"Arial",
|
||||
"San Francisco",
|
||||
".SF NS Text",
|
||||
".SF NS Display",
|
||||
"Segoe UI Variable",
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"SimSun",
|
||||
];
|
||||
|
||||
pub(crate) fn resolve_safe_ui_font(cx: &App, requested_font: &str) -> String {
|
||||
let all_fonts = cx.text_system().all_font_names();
|
||||
if requested_font == ".SystemUIFont" {
|
||||
let has_system_ui = COMMON_SYSTEM_UI_FONTS
|
||||
.iter()
|
||||
.any(|font| all_fonts.contains(&font.to_string()));
|
||||
if has_system_ui {
|
||||
".SystemUIFont".to_string()
|
||||
} else {
|
||||
"Maple Mono NF CN".to_string()
|
||||
}
|
||||
} else if all_fonts.contains(&requested_font.to_string()) {
|
||||
requested_font.to_string()
|
||||
} else {
|
||||
let has_system_ui = COMMON_SYSTEM_UI_FONTS
|
||||
.iter()
|
||||
.any(|font| all_fonts.contains(&font.to_string()));
|
||||
if has_system_ui {
|
||||
".SystemUIFont".to_string()
|
||||
} else {
|
||||
"Maple Mono NF CN".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
|
||||
let has_system_maple = cx
|
||||
.text_system()
|
||||
.all_font_names()
|
||||
.contains(&"Maple Mono NF CN".to_string());
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) fn load_embedded_themes(cx: &mut App) {
|
||||
let registry = ThemeRegistry::global_mut(cx);
|
||||
for theme_json in EMBEDDED_THEME_JSONS {
|
||||
if let Err(err) = registry.load_themes_from_str(theme_json) {
|
||||
tracing::warn!("failed to load embedded theme: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
|
||||
theme.font_family = ui_font_family.into();
|
||||
theme.mono_font_family = ui_font_family.into();
|
||||
}
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn switch_theme_mode(
|
||||
&mut self,
|
||||
mode: ThemeMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.follow_system_theme = false;
|
||||
self.theme_mode = mode;
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_theme(
|
||||
&mut self,
|
||||
name: SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
|
||||
self.status = format!("theme not found: {name}").into();
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
if theme_config.mode.is_dark() {
|
||||
self.dark_theme_name = name.clone();
|
||||
} else {
|
||||
self.light_theme_name = name.clone();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.status = format!("theme: {name}").into();
|
||||
self.persist_theme_preferences();
|
||||
window.refresh();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_follow_system_theme(
|
||||
&mut self,
|
||||
follow: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.follow_system_theme = follow;
|
||||
if follow {
|
||||
self.status = "theme mode: system".into();
|
||||
} else {
|
||||
self.status = format!("theme mode: {}", cx.theme().mode.name()).into();
|
||||
}
|
||||
self.apply_theme_preferences(window, cx);
|
||||
self.persist_theme_preferences();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_display_language(
|
||||
&mut self,
|
||||
locale: &str,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.config.set_locale(locale);
|
||||
let mut active_locale = locale.to_string();
|
||||
if active_locale == "system" {
|
||||
active_locale = sys_locale::get_locale().unwrap_or_else(|| "en".to_string());
|
||||
if active_locale.starts_with("zh") {
|
||||
active_locale = "zh-CN".to_string();
|
||||
} else {
|
||||
active_locale = "en".to_string();
|
||||
}
|
||||
}
|
||||
rust_i18n::set_locale(&active_locale);
|
||||
gpui_component::set_locale(&active_locale);
|
||||
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();
|
||||
}
|
||||
|
||||
pub(crate) fn apply_theme_preferences(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let light_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.light_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_light_theme().clone());
|
||||
let dark_theme = ThemeRegistry::global(cx)
|
||||
.themes()
|
||||
.get(&self.dark_theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
|
||||
let 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, &safe_font);
|
||||
|
||||
if self.follow_system_theme {
|
||||
Theme::sync_system_appearance(Some(window), cx);
|
||||
} else {
|
||||
Theme::change(self.theme_mode, Some(window), cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn persist_theme_preferences(&mut self) {
|
||||
let theme_mode_str = match self.theme_mode {
|
||||
ThemeMode::Light => "light",
|
||||
ThemeMode::Dark => "dark",
|
||||
};
|
||||
self.config.set_theme_preferences(
|
||||
self.follow_system_theme,
|
||||
theme_mode_str,
|
||||
self.light_theme_name.to_string(),
|
||||
self.dark_theme_name.to_string(),
|
||||
);
|
||||
self.save_preferences_background();
|
||||
}
|
||||
}
|
||||
+6041
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
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::session::config::LocalTerminalShell;
|
||||
use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender};
|
||||
|
||||
#[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: GuardedBackendEventSender,
|
||||
initial_directory: Option<&Path>,
|
||||
shell: LocalTerminalShell,
|
||||
) -> Result<BackendTx> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.context("open local PTY")?;
|
||||
|
||||
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 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()),
|
||||
);
|
||||
cmd.env(
|
||||
"COLORTERM",
|
||||
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
|
||||
);
|
||||
cmd.env("TERM_PROGRAM", "ashell");
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
cmd.env("LANG", lang);
|
||||
} else {
|
||||
cmd.env("LANG", "en_US.UTF-8");
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
cmd.env("HOME", home);
|
||||
}
|
||||
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;
|
||||
let mut reader = master.try_clone_reader().context("clone PTY reader")?;
|
||||
let mut writer = master.take_writer().context("take PTY writer")?;
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<BackendCommand>();
|
||||
|
||||
let read_tab = tab_id.clone();
|
||||
let read_events = events.clone();
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = read_events.send(BackendEvent::Output {
|
||||
tab_id: read_tab.clone(),
|
||||
bytes: buf[..n].to_vec(),
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = read_events.send(BackendEvent::Closed {
|
||||
tab_id: read_tab.clone(),
|
||||
reason: format!("local read error: {err}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = read_events.send(BackendEvent::Closed {
|
||||
tab_id: read_tab,
|
||||
reason: "local shell closed".into(),
|
||||
});
|
||||
});
|
||||
|
||||
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(Duration::from_millis(100)) {
|
||||
Ok(command) => match command {
|
||||
BackendCommand::Input(bytes) => {
|
||||
if let Err(err) = writer.write_all(&bytes) {
|
||||
let _ = write_events.send(BackendEvent::Closed {
|
||||
tab_id: write_tab.clone(),
|
||||
reason: format!("local write error: {err}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let _ = writer.flush();
|
||||
}
|
||||
BackendCommand::Resize { cols, rows } => {
|
||||
let _ = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
BackendCommand::SampleMetrics
|
||||
| BackendCommand::SampleProcesses
|
||||
| BackendCommand::SamplePorts
|
||||
| BackendCommand::TerminateProcess { .. } => {}
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
let _ = write_events.send(BackendEvent::Closed {
|
||||
tab_id: write_tab,
|
||||
reason: format!("local shell exited: {status}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id,
|
||||
text: "local shell ready".into(),
|
||||
});
|
||||
|
||||
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"/}""#
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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();
|
||||
}
|
||||
}
|
||||
+1161
File diff suppressed because it is too large
Load Diff
-193
@@ -1,193 +0,0 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
Key,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub auth: AuthMethod,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub private_key_path: String,
|
||||
#[serde(default)]
|
||||
pub private_key_inline: String,
|
||||
#[serde(default)]
|
||||
pub last_used: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn password(host: String, port: u16, user: String, password: String) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Password,
|
||||
password,
|
||||
private_key_path: String::new(),
|
||||
private_key_inline: String::new(),
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
private_key_path: String,
|
||||
private_key_inline: String,
|
||||
) -> Self {
|
||||
let name = format!("{user}@{host}");
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth: AuthMethod::Key,
|
||||
password: String::new(),
|
||||
private_key_path,
|
||||
private_key_inline,
|
||||
last_used: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ConfigFile {
|
||||
#[serde(default)]
|
||||
pub follow_system_theme: bool,
|
||||
#[serde(default)]
|
||||
pub light_theme_name: String,
|
||||
#[serde(default)]
|
||||
pub dark_theme_name: String,
|
||||
#[serde(default = "default_terminal_font_size")]
|
||||
pub terminal_font_size: f32,
|
||||
#[serde(default)]
|
||||
pub sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
fn default_terminal_font_size() -> f32 {
|
||||
13.0
|
||||
}
|
||||
|
||||
pub struct ConfigStore {
|
||||
path: PathBuf,
|
||||
cache: ConfigFile,
|
||||
}
|
||||
|
||||
impl ConfigStore {
|
||||
pub fn load() -> Result<Self> {
|
||||
let path = Self::config_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create config dir {}", parent.display()))?;
|
||||
}
|
||||
|
||||
let cache = if path.exists() {
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
serde_json::from_str::<ConfigFile>(&raw).unwrap_or_default()
|
||||
} else {
|
||||
ConfigFile::default()
|
||||
};
|
||||
|
||||
Ok(Self { path, cache })
|
||||
}
|
||||
|
||||
pub fn in_memory() -> Self {
|
||||
Self {
|
||||
path: PathBuf::new(),
|
||||
cache: ConfigFile::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> Result<PathBuf> {
|
||||
let dirs = BaseDirs::new().context("could not determine user home directory")?;
|
||||
Ok(dirs
|
||||
.home_dir()
|
||||
.join(".config")
|
||||
.join("ashell")
|
||||
.join("sessions.json"))
|
||||
}
|
||||
|
||||
pub fn sessions(&self) -> &[Session] {
|
||||
&self.cache.sessions
|
||||
}
|
||||
|
||||
pub fn follow_system_theme(&self) -> bool {
|
||||
self.cache.follow_system_theme
|
||||
}
|
||||
|
||||
pub fn light_theme_name(&self) -> &str {
|
||||
&self.cache.light_theme_name
|
||||
}
|
||||
|
||||
pub fn dark_theme_name(&self) -> &str {
|
||||
&self.cache.dark_theme_name
|
||||
}
|
||||
|
||||
pub fn terminal_font_size(&self) -> f32 {
|
||||
if self.cache.terminal_font_size <= 0.0 {
|
||||
default_terminal_font_size()
|
||||
} else {
|
||||
self.cache.terminal_font_size
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_theme_preferences(
|
||||
&mut self,
|
||||
follow_system_theme: bool,
|
||||
light_theme_name: impl Into<String>,
|
||||
dark_theme_name: impl Into<String>,
|
||||
) {
|
||||
self.cache.follow_system_theme = follow_system_theme;
|
||||
self.cache.light_theme_name = light_theme_name.into();
|
||||
self.cache.dark_theme_name = dark_theme_name.into();
|
||||
}
|
||||
|
||||
pub fn set_terminal_font_size(&mut self, terminal_font_size: f32) {
|
||||
self.cache.terminal_font_size = terminal_font_size.max(10.0);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&Session> {
|
||||
self.cache.sessions.iter().find(|s| s.id == id)
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, session: Session) {
|
||||
if let Some(existing) = self.cache.sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
*existing = session;
|
||||
} else {
|
||||
self.cache.sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &str) {
|
||||
self.cache.sessions.retain(|s| s.id != id);
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
if self.path.as_os_str().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&self.cache)?;
|
||||
fs::write(&self.path, raw).with_context(|| format!("failed to write {}", self.path.display()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
sync::mpsc::{self, Sender},
|
||||
thread,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
|
||||
|
||||
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
|
||||
|
||||
pub fn spawn_local_terminal(
|
||||
tab_id: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
events: Sender<BackendEvent>,
|
||||
) -> Result<BackendTx> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.context("open local PTY")?;
|
||||
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
|
||||
if cfg!(windows) {
|
||||
"powershell.exe".into()
|
||||
} else {
|
||||
"/bin/zsh".into()
|
||||
}
|
||||
});
|
||||
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
cmd.env("TERM", std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()));
|
||||
cmd.env(
|
||||
"COLORTERM",
|
||||
std::env::var("COLORTERM").unwrap_or_else(|_| "truecolor".into()),
|
||||
);
|
||||
cmd.env("TERM_PROGRAM", "ashell");
|
||||
if let Ok(path) = std::env::var("PATH") {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
if let Ok(lang) = std::env::var("LANG") {
|
||||
cmd.env("LANG", lang);
|
||||
} else {
|
||||
cmd.env("LANG", "en_US.UTF-8");
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
cmd.env("HOME", home);
|
||||
}
|
||||
cmd.env("SHELL", shell);
|
||||
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
|
||||
drop(pair.slave);
|
||||
|
||||
let master = pair.master;
|
||||
let mut reader = master.try_clone_reader().context("clone PTY reader")?;
|
||||
let mut writer = master.take_writer().context("take PTY writer")?;
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<BackendCommand>();
|
||||
|
||||
let read_tab = tab_id.clone();
|
||||
let read_events = events.clone();
|
||||
thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = read_events.send(BackendEvent::Output {
|
||||
tab_id: read_tab.clone(),
|
||||
bytes: buf[..n].to_vec(),
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = read_events.send(BackendEvent::Closed {
|
||||
tab_id: read_tab.clone(),
|
||||
reason: format!("local read error: {err}"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = read_events.send(BackendEvent::Closed {
|
||||
tab_id: read_tab,
|
||||
reason: "local shell closed".into(),
|
||||
});
|
||||
});
|
||||
|
||||
let write_tab = tab_id.clone();
|
||||
let write_events = events.clone();
|
||||
thread::spawn(move || {
|
||||
while let Ok(command) = cmd_rx.recv() {
|
||||
match command {
|
||||
BackendCommand::Input(bytes) => {
|
||||
if let Err(err) = writer.write_all(&bytes) {
|
||||
let _ = write_events.send(BackendEvent::Closed {
|
||||
tab_id: write_tab.clone(),
|
||||
reason: format!("local write error: {err}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
let _ = writer.flush();
|
||||
}
|
||||
BackendCommand::Resize { cols, rows } => {
|
||||
let _ = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
}
|
||||
BackendCommand::Close => break,
|
||||
}
|
||||
}
|
||||
let _ = child.kill();
|
||||
});
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id,
|
||||
text: "local shell ready".into(),
|
||||
});
|
||||
|
||||
Ok(BackendTx::Local(cmd_tx))
|
||||
}
|
||||
+45
-3285
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2902
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)
|
||||
}
|
||||
-917
@@ -1,917 +0,0 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use directories::BaseDirs;
|
||||
use flate2::read::GzDecoder;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
|
||||
Disconnect,
|
||||
};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
|
||||
task::JoinHandle,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use walkdir::WalkDir;
|
||||
use zip::read::ZipArchive;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
terminal::BackendEvent,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteEntry {
|
||||
pub name: String,
|
||||
pub full_path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
pub modified: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreviewData {
|
||||
pub path: String,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub is_binary: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SftpCommand {
|
||||
ListDir(String),
|
||||
Preview(String),
|
||||
Download { remote: String, local_dir: String },
|
||||
UploadPaths { locals: Vec<String>, remote_dir: String },
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct SftpHandle {
|
||||
commands: UnboundedSender<SftpCommand>,
|
||||
#[allow(dead_code)]
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Clone for SftpHandle {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
commands: self.commands.clone(),
|
||||
join: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SftpHandle {
|
||||
pub fn list_dir(&self, path: String) {
|
||||
let _ = self.commands.send(SftpCommand::ListDir(path));
|
||||
}
|
||||
|
||||
pub fn preview(&self, path: String) {
|
||||
let _ = self.commands.send(SftpCommand::Preview(path));
|
||||
}
|
||||
|
||||
pub fn download(&self, remote: String, local_dir: String) {
|
||||
let _ = self.commands.send(SftpCommand::Download { remote, local_dir });
|
||||
}
|
||||
|
||||
pub fn upload_paths(&self, locals: Vec<String>, remote_dir: String) {
|
||||
let _ = self.commands.send(SftpCommand::UploadPaths { locals, remote_dir });
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let _ = self.commands.send(SftpCommand::Close);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_sftp(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> SftpHandle {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
|
||||
let join = runtime.spawn(async move {
|
||||
if let Err(err) = run_sftp(tab_id.clone(), session, cmd_rx, events.clone()).await {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id,
|
||||
text: format!("sftp error: {err:#}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
SftpHandle {
|
||||
commands: cmd_tx,
|
||||
join: Some(join),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_sftp(
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
mut commands: UnboundedReceiver<SftpCommand>,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<()> {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: "sftp connecting...".into(),
|
||||
});
|
||||
|
||||
let handle = connect_and_authenticate(&session).await?;
|
||||
let channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open sftp channel")?;
|
||||
channel
|
||||
.request_subsystem(true, "sftp")
|
||||
.await
|
||||
.context("request sftp subsystem")?;
|
||||
let sftp = SftpSession::new(channel.into_stream())
|
||||
.await
|
||||
.context("sftp handshake")?;
|
||||
|
||||
let home = sftp.canonicalize(".").await.unwrap_or_else(|_| "/".to_string());
|
||||
emit_entries(&events, &tab_id, &sftp, &home).await?;
|
||||
|
||||
while let Some(command) = commands.recv().await {
|
||||
match command {
|
||||
SftpCommand::Close => break,
|
||||
SftpCommand::ListDir(path) => {
|
||||
let actual_path = if path == "~" {
|
||||
home.clone()
|
||||
} else if let Some(rest) = path.strip_prefix("~/") {
|
||||
crate::sftp::join_remote(&home, rest)
|
||||
} else {
|
||||
path
|
||||
};
|
||||
|
||||
if let Err(err) = emit_entries(&events, &tab_id, &sftp, &actual_path).await {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("list failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
SftpCommand::Preview(path) => match preview_impl(&sftp, &path).await {
|
||||
Ok(preview) => {
|
||||
let _ = events.send(BackendEvent::SftpPreview {
|
||||
tab_id: tab_id.clone(),
|
||||
preview,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("preview failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
},
|
||||
SftpCommand::Download { remote, local_dir } => {
|
||||
let base = base_name(&remote);
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("downloading {base}..."),
|
||||
});
|
||||
match download_path_impl(&handle, &sftp, &remote, Path::new(&local_dir)).await {
|
||||
Ok(summary) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: summary,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("download failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
SftpCommand::UploadPaths { locals, remote_dir } => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: "uploading...".into(),
|
||||
});
|
||||
match upload_paths_impl(&sftp, &locals, &remote_dir).await {
|
||||
Ok(summary) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: summary,
|
||||
});
|
||||
let _ = emit_entries(&events, &tab_id, &sftp, &remote_dir).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("upload failed: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle.disconnect(Disconnect::ByApplication, "bye", "").await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_entries(
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
tab_id: &str,
|
||||
sftp: &SftpSession,
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
let entries = list_dir_impl(sftp, path).await?;
|
||||
let _ = events.send(BackendEvent::SftpEntries {
|
||||
tab_id: tab_id.to_string(),
|
||||
path: path.to_string(),
|
||||
entries,
|
||||
});
|
||||
let _ = events.send(BackendEvent::SftpStatus {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: path.to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_and_authenticate(session: &Session) -> Result<russh::client::Handle<SftpClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
let mut handle = client::connect(config, addr.as_str(), SftpClientHandler)
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => handle
|
||||
.authenticate_password(&session.user, &session.password)
|
||||
.await
|
||||
.context("password authentication failed")?,
|
||||
AuthMethod::Key => {
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.context("public key authentication failed")?
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
return Err(anyhow!(
|
||||
"authentication failed: server rejected {} authentication for {}@{}:{}",
|
||||
match session.auth {
|
||||
AuthMethod::Password => "password",
|
||||
AuthMethod::Key => "public key",
|
||||
},
|
||||
session.user,
|
||||
session.host,
|
||||
session.port
|
||||
));
|
||||
}
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
let inline_key = normalize_inline_private_key(&session.private_key_inline);
|
||||
let key_path = expand_key_path(session.private_key_path.trim());
|
||||
let has_inline = !inline_key.is_empty();
|
||||
let has_path = key_path.is_some();
|
||||
|
||||
if !has_inline && !has_path {
|
||||
return Err(anyhow!("private key content or path is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if has_inline {
|
||||
match decode_secret_key(&inline_key, None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("decode private key content: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = key_path {
|
||||
match load_secret_key(path.as_path(), None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value == "~" {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("~/") {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
|
||||
}
|
||||
Some(Path::new(value).to_path_buf())
|
||||
}
|
||||
|
||||
fn base_name(path: &str) -> String {
|
||||
let sep = |c: char| c == '/' || c == '\\';
|
||||
path.trim_end_matches(sep)
|
||||
.rsplit(sep)
|
||||
.next()
|
||||
.unwrap_or(path)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn join_remote(parent: &str, child: &str) -> String {
|
||||
if parent == "/" {
|
||||
format!("/{child}")
|
||||
} else {
|
||||
format!("{}/{}", parent.trim_end_matches('/'), child)
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_archive_suffix(name: &str) -> &str {
|
||||
for suffix in [".tar.gz", ".tgz", ".zip", ".tar"] {
|
||||
if let Some(stripped) = name.strip_suffix(suffix) {
|
||||
return stripped;
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{bytes} B")
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.1} KB", bytes as f64 / 1024.0)
|
||||
} else if bytes < 1024 * 1024 * 1024 {
|
||||
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
} else {
|
||||
format!("{:.2} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_mtime(ts: u32) -> String {
|
||||
let dt: DateTime<Utc> = Utc
|
||||
.timestamp_opt(ts as i64, 0)
|
||||
.single()
|
||||
.unwrap_or_else(Utc::now);
|
||||
dt.format("%Y-%m-%d %H:%M").to_string()
|
||||
}
|
||||
|
||||
async fn list_dir_impl(sftp: &SftpSession, path: &str) -> Result<Vec<RemoteEntry>> {
|
||||
let raw = sftp
|
||||
.read_dir(path)
|
||||
.await
|
||||
.with_context(|| format!("read_dir {path} failed"))?;
|
||||
|
||||
let mut entries = raw
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
let name = entry.file_name();
|
||||
name != "." && name != ".."
|
||||
})
|
||||
.map(|entry| {
|
||||
let name = entry.file_name().to_string();
|
||||
let full_path = join_remote(path, &name);
|
||||
let meta = entry.metadata();
|
||||
let permissions = meta.permissions.unwrap_or(0);
|
||||
let is_dir = (permissions & 0o170_000) == 0o040_000;
|
||||
let size = meta.size.unwrap_or(0);
|
||||
let modified = meta.mtime.unwrap_or(0);
|
||||
RemoteEntry {
|
||||
name,
|
||||
full_path,
|
||||
is_dir,
|
||||
size,
|
||||
modified,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
async fn preview_impl(sftp: &SftpSession, path: &str) -> Result<PreviewData> {
|
||||
let metadata = sftp.metadata(path).await.with_context(|| format!("metadata {path}"))?;
|
||||
let is_dir = metadata
|
||||
.permissions
|
||||
.map(|mode| (mode & 0o170_000) == 0o040_000)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_dir {
|
||||
let entries = list_dir_impl(sftp, path).await?;
|
||||
let mut lines = vec![format!("Directory: {path}"), String::new()];
|
||||
for entry in entries.into_iter().take(200) {
|
||||
let kind = if entry.is_dir { "dir " } else { "file" };
|
||||
lines.push(format!("{kind} {}", entry.name));
|
||||
}
|
||||
return Ok(PreviewData {
|
||||
path: path.to_string(),
|
||||
title: base_name(path),
|
||||
body: lines.join("\n"),
|
||||
is_binary: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut remote_file = sftp.open(path).await.with_context(|| format!("open remote {path}"))?;
|
||||
let mut buffer = vec![0u8; 128 * 1024];
|
||||
let read = remote_file.read(&mut buffer).await.context("read preview bytes")?;
|
||||
buffer.truncate(read);
|
||||
|
||||
let nul_ratio = if buffer.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
buffer.iter().filter(|byte| **byte == 0).count() as f32 / buffer.len() as f32
|
||||
};
|
||||
let is_binary = nul_ratio > 0.01;
|
||||
let body = if is_binary {
|
||||
format!(
|
||||
"Binary file\npath: {path}\nsize: {}\npreview: unavailable in-app",
|
||||
format_bytes(metadata.size.unwrap_or(0)),
|
||||
)
|
||||
} else {
|
||||
String::from_utf8_lossy(&buffer).into_owned()
|
||||
};
|
||||
|
||||
Ok(PreviewData {
|
||||
path: path.to_string(),
|
||||
title: base_name(path),
|
||||
body,
|
||||
is_binary,
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_path_impl(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
sftp: &SftpSession,
|
||||
remote: &str,
|
||||
local_dir: &Path,
|
||||
) -> Result<String> {
|
||||
tokio::fs::create_dir_all(local_dir)
|
||||
.await
|
||||
.with_context(|| format!("create {}", local_dir.display()))?;
|
||||
|
||||
let metadata = sftp.metadata(remote).await.with_context(|| format!("metadata {remote}"))?;
|
||||
let is_dir = metadata
|
||||
.permissions
|
||||
.map(|mode| (mode & 0o170_000) == 0o040_000)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_dir {
|
||||
let local_archive = local_dir.join(format!(
|
||||
".ashell-{}-{}.tar.gz",
|
||||
base_name(remote),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let extracted_to = download_remote_directory_archive(handle, sftp, remote, &local_archive).await?;
|
||||
return Ok(format!("downloaded folder to {}", extracted_to.display()));
|
||||
}
|
||||
|
||||
let local_path = local_dir.join(base_name(remote));
|
||||
download_file_impl(sftp, remote, &local_path).await?;
|
||||
Ok(format!("downloaded file to {}", local_path.display()))
|
||||
}
|
||||
|
||||
async fn download_dir_recursive(sftp: &SftpSession, remote_dir: &str, local_dir: &Path) -> Result<()> {
|
||||
tokio::fs::create_dir_all(local_dir)
|
||||
.await
|
||||
.with_context(|| format!("create {}", local_dir.display()))?;
|
||||
let entries = list_dir_impl(sftp, remote_dir).await?;
|
||||
for entry in entries {
|
||||
let local_path = local_dir.join(&entry.name);
|
||||
if entry.is_dir {
|
||||
Box::pin(download_dir_recursive(sftp, &entry.full_path, &local_path)).await?;
|
||||
} else {
|
||||
download_file_impl(sftp, &entry.full_path, &local_path).await?;
|
||||
let _ = maybe_extract_archive(&local_path).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_remote_directory_archive(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
sftp: &SftpSession,
|
||||
remote_dir: &str,
|
||||
local_archive: &Path,
|
||||
) -> Result<PathBuf> {
|
||||
let remote_archive = format!("/tmp/ashell-{}-{}.tar.gz", base_name(remote_dir), Uuid::new_v4());
|
||||
create_remote_archive(handle, remote_dir, &remote_archive).await?;
|
||||
let local_extract_root = local_archive
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(base_name(remote_dir));
|
||||
|
||||
let archive_download = async {
|
||||
download_file_impl(sftp, &remote_archive, local_archive).await?;
|
||||
extract_archive_to(local_archive, local_archive.parent().unwrap_or_else(|| Path::new("."))).await?;
|
||||
tokio::fs::remove_file(local_archive)
|
||||
.await
|
||||
.with_context(|| format!("remove {}", local_archive.display()))?;
|
||||
Ok::<PathBuf, anyhow::Error>(local_extract_root)
|
||||
}
|
||||
.await;
|
||||
|
||||
let cleanup_result = remove_remote_path(handle, &remote_archive).await;
|
||||
|
||||
let extracted_to = archive_download?;
|
||||
if let Err(err) = cleanup_result {
|
||||
tracing::warn!("failed to clean remote archive {remote_archive}: {err:#}");
|
||||
}
|
||||
|
||||
Ok(extracted_to)
|
||||
}
|
||||
|
||||
async fn download_file_impl(sftp: &SftpSession, remote: &str, local: &Path) -> Result<()> {
|
||||
let mut remote_file = sftp.open(remote).await.with_context(|| format!("open remote {remote}"))?;
|
||||
let mut local_file = tokio::fs::File::create(local)
|
||||
.await
|
||||
.with_context(|| format!("create local {}", local.display()))?;
|
||||
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = remote_file.read(&mut buffer).await.context("read remote file")?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
local_file
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write {}", local.display()))?;
|
||||
}
|
||||
local_file.flush().await.context("flush local file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_paths_impl(sftp: &SftpSession, locals: &[String], remote_dir: &str) -> Result<String> {
|
||||
create_remote_dir_all(sftp, remote_dir).await?;
|
||||
let mut file_count = 0usize;
|
||||
let mut folder_count = 0usize;
|
||||
for local in locals {
|
||||
let path = PathBuf::from(local);
|
||||
if path.is_dir() {
|
||||
upload_directory_impl(sftp, &path, remote_dir).await?;
|
||||
folder_count += 1;
|
||||
} else {
|
||||
upload_file_to_dir_impl(sftp, &path, remote_dir).await?;
|
||||
file_count += 1;
|
||||
}
|
||||
}
|
||||
let summary = match (file_count, folder_count) {
|
||||
(1, 0) => "uploaded file".to_string(),
|
||||
(0, 1) => "uploaded folder".to_string(),
|
||||
(files, 0) => format!("uploaded {files} files"),
|
||||
(0, folders) => format!("uploaded {folders} folders"),
|
||||
(files, folders) => format!("uploaded {files} files and {folders} folders"),
|
||||
};
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
async fn upload_directory_impl(sftp: &SftpSession, local_dir: &Path, remote_parent: &str) -> Result<()> {
|
||||
let root_name = local_dir
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("invalid folder name: {}", local_dir.display()))?;
|
||||
let remote_root = join_remote(remote_parent, root_name);
|
||||
create_remote_dir_all(sftp, &remote_root).await?;
|
||||
|
||||
for entry in WalkDir::new(local_dir) {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path == local_dir {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(local_dir)?;
|
||||
let remote_path = if relative.as_os_str().is_empty() {
|
||||
remote_root.clone()
|
||||
} else {
|
||||
let rel = relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
join_remote(&remote_root, &rel)
|
||||
};
|
||||
if path.is_dir() {
|
||||
create_remote_dir_all(sftp, &remote_path).await?;
|
||||
} else {
|
||||
if let Some(parent) = Path::new(&remote_path).parent() {
|
||||
let parent_remote = parent.to_string_lossy().replace('\\', "/");
|
||||
create_remote_dir_all(sftp, &parent_remote).await?;
|
||||
}
|
||||
upload_file_impl(sftp, path, &remote_path).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file_to_dir_impl(sftp: &SftpSession, local_file: &Path, remote_dir: &str) -> Result<()> {
|
||||
let file_name = local_file
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| anyhow!("invalid file name: {}", local_file.display()))?;
|
||||
let remote_path = join_remote(remote_dir, file_name);
|
||||
upload_file_impl(sftp, local_file, &remote_path).await
|
||||
}
|
||||
|
||||
async fn upload_file_impl(sftp: &SftpSession, local_file: &Path, remote_path: &str) -> Result<()> {
|
||||
let mut local = tokio::fs::File::open(local_file)
|
||||
.await
|
||||
.with_context(|| format!("open local {}", local_file.display()))?;
|
||||
let mut remote = sftp
|
||||
.create(remote_path)
|
||||
.await
|
||||
.with_context(|| format!("create remote {remote_path}"))?;
|
||||
|
||||
let mut buffer = vec![0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = local.read(&mut buffer).await.context("read local file")?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
remote
|
||||
.write_all(&buffer[..read])
|
||||
.await
|
||||
.with_context(|| format!("write remote {remote_path}"))?;
|
||||
}
|
||||
remote.flush().await.context("flush remote file")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_remote_dir_all(sftp: &SftpSession, remote_dir: &str) -> Result<()> {
|
||||
if remote_dir.is_empty() || remote_dir == "/" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut current = String::from("/");
|
||||
for segment in remote_dir.split('/').filter(|segment| !segment.is_empty()) {
|
||||
current = join_remote(¤t, segment);
|
||||
let _ = sftp.create_dir(¤t).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_remote_archive(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
remote_dir: &str,
|
||||
remote_archive: &str,
|
||||
) -> Result<()> {
|
||||
let remote_dir = remote_dir.trim_end_matches('/');
|
||||
let parent = remote_parent(remote_dir);
|
||||
let name = base_name(remote_dir);
|
||||
let command = format!(
|
||||
"tar -C {} -czf {} {}",
|
||||
shell_quote(&parent),
|
||||
shell_quote(remote_archive),
|
||||
shell_quote(&name),
|
||||
);
|
||||
exec_remote_command(handle, &command)
|
||||
.await
|
||||
.with_context(|| format!("archive remote directory {remote_dir}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_remote_path(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
remote_path: &str,
|
||||
) -> Result<()> {
|
||||
let command = format!("rm -f {}", shell_quote(remote_path));
|
||||
exec_remote_command(handle, &command)
|
||||
.await
|
||||
.with_context(|| format!("remove remote temporary file {remote_path}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_remote_command(
|
||||
handle: &russh::client::Handle<SftpClientHandler>,
|
||||
command: &str,
|
||||
) -> Result<()> {
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open remote exec session")?;
|
||||
channel
|
||||
.exec(true, command)
|
||||
.await
|
||||
.with_context(|| format!("exec remote command: {command}"))?;
|
||||
|
||||
let mut stderr = Vec::new();
|
||||
let mut stdout = Vec::new();
|
||||
let mut exit_status = None;
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data),
|
||||
russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code),
|
||||
russh::ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match exit_status.unwrap_or(0) {
|
||||
0 => Ok(()),
|
||||
code => {
|
||||
let stderr = String::from_utf8_lossy(&stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&stdout).trim().to_string();
|
||||
Err(anyhow!(
|
||||
"remote command exited with {code}: {}",
|
||||
if !stderr.is_empty() { stderr } else { stdout }
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_parent(path: &str) -> String {
|
||||
if path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path.rsplit_once('/')
|
||||
.map(|(parent, _)| {
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\"'\"'"))
|
||||
}
|
||||
|
||||
async fn maybe_extract_archive(path: &Path) -> Result<Option<PathBuf>> {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let is_archive = [".zip", ".tar", ".tar.gz", ".tgz"]
|
||||
.iter()
|
||||
.any(|suffix| file_name.ends_with(suffix));
|
||||
if !is_archive {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let extract_root = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join(strip_archive_suffix(&file_name));
|
||||
let archive_path = path.to_path_buf();
|
||||
let target_dir = extract_root.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
fs::create_dir_all(&target_dir)
|
||||
.with_context(|| format!("create {}", target_dir.display()))?;
|
||||
|
||||
if file_name.ends_with(".zip") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut zip = ZipArchive::new(file).context("read zip archive")?;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).context("read zip entry")?;
|
||||
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output = target_dir.join(name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output)?;
|
||||
} else {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output_file = fs::File::create(&output)?;
|
||||
std::io::copy(&mut entry, &mut output_file)?;
|
||||
}
|
||||
}
|
||||
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
|
||||
} else if file_name.ends_with(".tar") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&target_dir).context("unpack tar archive")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("extract archive task join failure")??;
|
||||
|
||||
Ok(Some(extract_root))
|
||||
}
|
||||
|
||||
async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()).map(|name| name.to_string()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let archive_path = path.to_path_buf();
|
||||
let target_dir = target_dir.to_path_buf();
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
fs::create_dir_all(&target_dir)
|
||||
.with_context(|| format!("create {}", target_dir.display()))?;
|
||||
|
||||
if file_name.ends_with(".zip") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut zip = ZipArchive::new(file).context("read zip archive")?;
|
||||
for index in 0..zip.len() {
|
||||
let mut entry = zip.by_index(index).context("read zip entry")?;
|
||||
let Some(name) = entry.enclosed_name().map(|name| name.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let output = target_dir.join(name);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output)?;
|
||||
} else {
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut output_file = fs::File::create(&output)?;
|
||||
std::io::copy(&mut entry, &mut output_file)?;
|
||||
}
|
||||
}
|
||||
} else if file_name.ends_with(".tar.gz") || file_name.ends_with(".tgz") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let decoder = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&target_dir).context("unpack tar.gz archive")?;
|
||||
} else if file_name.ends_with(".tar") {
|
||||
let file = fs::File::open(&archive_path)
|
||||
.with_context(|| format!("open {}", archive_path.display()))?;
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&target_dir).context("unpack tar archive")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("extract archive task join failure")??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct SftpClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for SftpClientHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
+1957
File diff suppressed because it is too large
Load Diff
+525
@@ -0,0 +1,525 @@
|
||||
use gpui::{Context, PathPromptOptions, Pixels, Point, Window};
|
||||
|
||||
use crate::{
|
||||
Ashell, SftpContextMenuState,
|
||||
sftp::{RemoteEntry, SftpHandle},
|
||||
terminal,
|
||||
};
|
||||
|
||||
impl Ashell {
|
||||
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
|
||||
.and_then(|g| g.sftp.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
|
||||
let active_id = self.active_group.clone()?;
|
||||
self.tab_groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == active_id)
|
||||
.and_then(|g| g.sftp.as_mut())
|
||||
}
|
||||
|
||||
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
|
||||
self.active_group
|
||||
.as_ref()
|
||||
.and_then(|id| self.sftp_handles.get(id))
|
||||
}
|
||||
|
||||
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_sftp_entry_selected(&mut self, path: &str, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.selected_path = Some(path.to_string());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn sftp_parent_path(path: &str) -> String {
|
||||
if path == "/" {
|
||||
return "/".to_string();
|
||||
}
|
||||
path.trim_end_matches('/')
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| {
|
||||
if parent.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
parent.to_string()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "/".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_sftp(&mut self, cx: &mut Context<Self>) {
|
||||
if let Some(path) = self.active_sftp().map(|sftp| sftp.current_path.clone()) {
|
||||
self.navigate_sftp(path, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sync_sftp_path_input(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(path) = self.pending_sftp_path_sync.take() else {
|
||||
return;
|
||||
};
|
||||
self.sftp_path_input.update(cx, |state, cx| {
|
||||
state.set_value(path, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn open_sftp_context_menu(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
is_dir: bool,
|
||||
position: Point<Pixels>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.sftp_context_menu = Some(SftpContextMenuState {
|
||||
remote_path,
|
||||
is_dir,
|
||||
position,
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn dismiss_sftp_context_menu(&mut self, cx: &mut Context<Self>) {
|
||||
if self.sftp_context_menu.take().is_some() {
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_sftp_context_download(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
self.download_sftp_entry(menu.remote_path, window, cx);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_sftp_context_edit(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(menu) = self.sftp_context_menu.take() else {
|
||||
return;
|
||||
};
|
||||
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(
|
||||
&mut self,
|
||||
remote_path: String,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Download Folder".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
let local_path = folder.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating download of '{}' to '{}'",
|
||||
remote_path,
|
||||
local_path
|
||||
);
|
||||
handle.download(remote_path, local_path);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("download picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_files(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("Select File to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(file) = paths.pop() {
|
||||
let local_path = file.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating upload of file '{}' to '{}'",
|
||||
local_path,
|
||||
remote_dir
|
||||
);
|
||||
handle.upload_paths(vec![local_path], remote_dir);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
let remote_dir = self
|
||||
.active_sftp()
|
||||
.map(|sftp| sftp.current_path.clone())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Folder to Upload".into()),
|
||||
});
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match path_prompt.await {
|
||||
Ok(Ok(Some(mut paths))) => {
|
||||
if let Some(folder) = paths.pop() {
|
||||
let local_path = folder.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating upload of folder '{}' to '{}'",
|
||||
local_path,
|
||||
remote_dir
|
||||
);
|
||||
handle.upload_paths(vec![local_path], remote_dir);
|
||||
this.update(cx, |this, cx| {
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.status = format!("upload picker failed: {err}").into();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_sftp_entry(
|
||||
&mut self,
|
||||
path: String,
|
||||
checked: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
sftp.selected_path = Some(path.clone());
|
||||
sftp.selection_anchor = Some(path.clone());
|
||||
if checked {
|
||||
sftp.selected_entries.insert(path);
|
||||
} else {
|
||||
sftp.selected_entries.remove(&path);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
|
||||
if let Some(sftp) = self.active_sftp_mut() {
|
||||
if checked {
|
||||
let paths: Vec<String> = sftp
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn download_selected_sftp_entries(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(sftp) = self.active_sftp() else {
|
||||
return;
|
||||
};
|
||||
let selected: Vec<String> = sftp.selected_entries.iter().cloned().collect();
|
||||
if selected.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(handle) = self.active_sftp_handle().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let path_prompt = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select Download Folder".into()),
|
||||
});
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
|
||||
if let Some(folder) = paths.pop() {
|
||||
let local_dir = folder.to_string_lossy().to_string();
|
||||
tracing::info!(
|
||||
"[sftp] initiating batch download of {} entries to '{}'",
|
||||
selected.len(),
|
||||
local_dir
|
||||
);
|
||||
for remote in selected {
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
|
||||
remote,
|
||||
local_dir: local_dir.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
if let Some(sftp_mut) = this.active_sftp_mut() {
|
||||
sftp_mut.selected_entries.clear();
|
||||
}
|
||||
this.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, cx: &mut Context<Self>) {
|
||||
if paths.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(sftp) = self.active_sftp() {
|
||||
if let Some(handle) = self.active_sftp_handle() {
|
||||
tracing::info!(
|
||||
"[sftp] initiating batch upload of {} files to '{}'",
|
||||
paths.len(),
|
||||
sftp.current_path
|
||||
);
|
||||
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
|
||||
locals: paths,
|
||||
remote_dir: sftp.current_path.clone(),
|
||||
});
|
||||
self.show_transfers_dialog = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use directories::BaseDirs;
|
||||
use russh::{
|
||||
client::{self, Handler},
|
||||
keys::{decode_secret_key, key::PrivateKeyWithHashAlg, load_secret_key, HashAlg, PrivateKey},
|
||||
ChannelMsg, Disconnect,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
config::{AuthMethod, Session},
|
||||
system::{remote_snapshot_from_kv, SystemSnapshot},
|
||||
terminal::{BackendCommand, BackendEvent, BackendTx},
|
||||
};
|
||||
|
||||
pub fn spawn_ssh_terminal(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> BackendTx {
|
||||
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
|
||||
let task_tab = tab_id.clone();
|
||||
runtime.spawn(async move {
|
||||
if let Err(err) = run_ssh(
|
||||
task_tab.clone(),
|
||||
session,
|
||||
cols,
|
||||
rows,
|
||||
cmd_rx,
|
||||
events.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: task_tab,
|
||||
reason: format!("{err:#}"),
|
||||
});
|
||||
}
|
||||
});
|
||||
BackendTx::Ssh(cmd_tx)
|
||||
}
|
||||
|
||||
pub async fn sample_remote_system(session: Session) -> Result<SystemSnapshot> {
|
||||
let (events_tx, _events_rx) = std::sync::mpsc::channel();
|
||||
let handle = connect_and_authenticate("remote-metrics", &session, &events_tx).await?;
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open metrics session")?;
|
||||
channel
|
||||
.exec(true, REMOTE_SYSTEM_PROBE)
|
||||
.await
|
||||
.context("exec remote metrics probe")?;
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, ext: _ } => {
|
||||
stdout.extend_from_slice(&data);
|
||||
}
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "metrics done", "")
|
||||
.await;
|
||||
let output = String::from_utf8_lossy(&stdout);
|
||||
remote_snapshot_from_kv(&output)
|
||||
}
|
||||
|
||||
async fn run_ssh(
|
||||
tab_id: String,
|
||||
session: Session,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
mut commands: mpsc::UnboundedReceiver<BackendCommand>,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<()> {
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!(
|
||||
"connecting {}@{}:{}...",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
});
|
||||
|
||||
let handle = connect_and_authenticate(&tab_id, &session, &events).await?;
|
||||
|
||||
let mut channel = handle
|
||||
.channel_open_session()
|
||||
.await
|
||||
.context("open session")?;
|
||||
channel
|
||||
.request_pty(true, "xterm-256color", cols.into(), rows.into(), 0, 0, &[])
|
||||
.await
|
||||
.context("request pty")?;
|
||||
channel.request_shell(true).await.context("request shell")?;
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.clone(),
|
||||
text: format!("connected {}@{}", session.user, session.host),
|
||||
});
|
||||
let _ = events.send(BackendEvent::Connected {
|
||||
tab_id: tab_id.clone(),
|
||||
});
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => {
|
||||
match command {
|
||||
Some(BackendCommand::Input(bytes)) => {
|
||||
if let Err(err) = channel.data(bytes.as_slice()).await {
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id: tab_id.clone(),
|
||||
reason: format!("ssh write error: {err}"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(BackendCommand::Resize { cols, rows }) => {
|
||||
let _ = channel.window_change(cols.into(), rows.into(), 0, 0).await;
|
||||
}
|
||||
Some(BackendCommand::Close) | None => {
|
||||
let _ = channel.eof().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = channel.wait() => {
|
||||
match msg {
|
||||
Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
|
||||
let _ = events.send(BackendEvent::Output {
|
||||
tab_id: tab_id.clone(),
|
||||
bytes: data.to_vec(),
|
||||
});
|
||||
}
|
||||
Some(ChannelMsg::Close) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "bye", "")
|
||||
.await;
|
||||
let _ = events.send(BackendEvent::Closed {
|
||||
tab_id,
|
||||
reason: "ssh session closed".into(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_and_authenticate(
|
||||
tab_id: &str,
|
||||
session: &Session,
|
||||
events: &std::sync::mpsc::Sender<BackendEvent>,
|
||||
) -> Result<russh::client::Handle<ClientHandler>> {
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: Some(std::time::Duration::from_secs(600)),
|
||||
..Default::default()
|
||||
});
|
||||
let addr = format!("{}:{}", session.host, session.port);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("opening tcp connection to {addr}"),
|
||||
});
|
||||
let mut handle = client::connect(config, addr.as_str(), ClientHandler)
|
||||
.await
|
||||
.with_context(|| format!("connect {addr} failed"))?;
|
||||
|
||||
let authed = match session.auth {
|
||||
AuthMethod::Password => {
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"connected to {addr}, sending password authentication for {}",
|
||||
session.user
|
||||
),
|
||||
});
|
||||
handle
|
||||
.authenticate_password(&session.user, &session.password)
|
||||
.await
|
||||
.context("password authentication failed")?
|
||||
}
|
||||
AuthMethod::Key => {
|
||||
let source = key_source_label(session);
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("connected to {addr}, loading private key from {source}"),
|
||||
});
|
||||
let keypair = load_session_private_key(session)?;
|
||||
let algorithm = format!("{:?}", keypair.algorithm());
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!("private key loaded from {source}, algorithm {algorithm}, sending public key authentication for {}", session.user),
|
||||
});
|
||||
let key = private_key_with_alg(keypair).context("invalid private key")?;
|
||||
handle
|
||||
.authenticate_publickey(&session.user, key)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"public key authentication failed for {}@{}:{} using {} ({})",
|
||||
session.user, session.host, session.port, source, algorithm
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
if !authed {
|
||||
let _ = handle
|
||||
.disconnect(Disconnect::ByApplication, "auth failed", "")
|
||||
.await;
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
match session.auth {
|
||||
AuthMethod::Password => format!(
|
||||
"authentication failed: server rejected password authentication for {}@{}:{}",
|
||||
session.user, session.host, session.port
|
||||
),
|
||||
AuthMethod::Key => format!(
|
||||
"authentication failed: server rejected public key authentication for {}@{}:{} using {}",
|
||||
session.user,
|
||||
session.host,
|
||||
session.port,
|
||||
key_source_label(session)
|
||||
),
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
let _ = events.send(BackendEvent::Status {
|
||||
tab_id: tab_id.to_string(),
|
||||
text: format!(
|
||||
"authentication accepted, opening shell for {}@{}",
|
||||
session.user, session.host
|
||||
),
|
||||
});
|
||||
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
fn load_session_private_key(session: &Session) -> Result<PrivateKey> {
|
||||
let inline_key = normalize_inline_private_key(&session.private_key_inline);
|
||||
let key_path = expand_key_path(session.private_key_path.trim());
|
||||
let has_inline = !inline_key.is_empty();
|
||||
let has_path = key_path.is_some();
|
||||
|
||||
if !has_inline && !has_path {
|
||||
return Err(anyhow!("private key content or path is required"));
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if has_inline {
|
||||
match decode_secret_key(&inline_key, None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("decode private key content: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = key_path {
|
||||
match load_secret_key(path.as_path(), None) {
|
||||
Ok(key) => return Ok(key),
|
||||
Err(err) => errors.push(format!("load key {}: {err}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(errors.join("; ")))
|
||||
}
|
||||
|
||||
fn private_key_with_alg(keypair: PrivateKey) -> Result<PrivateKeyWithHashAlg> {
|
||||
let hash_alg = if keypair.algorithm().is_rsa() {
|
||||
Some(HashAlg::Sha512)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(
|
||||
PrivateKeyWithHashAlg::new(Arc::new(keypair.clone()), hash_alg)
|
||||
.or_else(|_| PrivateKeyWithHashAlg::new(Arc::new(keypair), Some(HashAlg::Sha256)))?,
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_inline_private_key(value: &str) -> String {
|
||||
let mut normalized = value
|
||||
.trim()
|
||||
.replace("\\r\\n", "\n")
|
||||
.replace("\\n", "\n")
|
||||
.replace("\r\n", "\n");
|
||||
if !normalized.ends_with('\n') {
|
||||
normalized.push('\n');
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn expand_key_path(value: &str) -> Option<PathBuf> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if value == "~" {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf());
|
||||
}
|
||||
if let Some(rest) = value.strip_prefix("~/") {
|
||||
return BaseDirs::new().map(|dirs| dirs.home_dir().join(rest));
|
||||
}
|
||||
Some(Path::new(value).to_path_buf())
|
||||
}
|
||||
|
||||
fn key_source_label(session: &Session) -> String {
|
||||
let path = session.private_key_path.trim();
|
||||
let has_inline = !session.private_key_inline.trim().is_empty();
|
||||
match (!path.is_empty(), has_inline) {
|
||||
(true, true) => format!("inline key or {}", path),
|
||||
(true, false) => path.to_string(),
|
||||
(false, true) => "inline key text".to_string(),
|
||||
(false, false) => "unknown key source".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const REMOTE_SYSTEM_PROBE: &str = r#"sh -lc '
|
||||
os=$(uname -s 2>/dev/null || echo unknown)
|
||||
|
||||
if [ "$os" = "Linux" ] && [ -r /proc/stat ]; then
|
||||
cpu_stat() { awk '"'"'/^cpu / { print ($2+$3+$4+$5+$6+$7+$8), $5 }'"'"' /proc/stat 2>/dev/null; }
|
||||
net_stat() { awk -F"[: ]+" '"'"'/:/ && $1!="Inter" && $1!="face" { rx += $3; tx += $11 } END { print rx+0, tx+0 }'"'"' /proc/net/dev 2>/dev/null; }
|
||||
|
||||
read cpu_total_1 cpu_idle_1 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read cpu_total_2 cpu_idle_2 <<EOF
|
||||
$(cpu_stat)
|
||||
EOF
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_delta=$((cpu_total_2 - cpu_total_1))
|
||||
idle_delta=$((cpu_idle_2 - cpu_idle_1))
|
||||
cpu_percent=$(awk -v total="$cpu_delta" -v idle="$idle_delta" '"'"'BEGIN { if (total <= 0) print "0.00"; else printf "%.2f", ((total-idle)/total)*100 }'"'"')
|
||||
mem_total=$(awk '"'"'/^MemTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
mem_available=$(awk '"'"'/^MemAvailable:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_total=$(awk '"'"'/^SwapTotal:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
swap_free=$(awk '"'"'/^SwapFree:/ {print $2 * 1024}'"'"' /proc/meminfo 2>/dev/null)
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=$(( ${mem_total:-0} - ${mem_available:-0} ))"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=$(( ${swap_total:-0} - ${swap_free:-0} ))"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$os" = "Darwin" ]; then
|
||||
net_stat() { netstat -ibn 2>/dev/null | awk '"'"'NR > 1 && $7 ~ /^[0-9]+$/ && $10 ~ /^[0-9]+$/ { rx += $7; tx += $10 } END { print rx+0, tx+0 }'"'"'; }
|
||||
|
||||
read net_rx_1 net_tx_1 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
sleep 1
|
||||
read net_rx_2 net_tx_2 <<EOF
|
||||
$(net_stat)
|
||||
EOF
|
||||
|
||||
cpu_percent=$(top -l 2 -n 0 -s 1 2>/dev/null | awk -F"[:,% ]+" '"'"'/CPU usage:/ { user=$3; sys=$5 } END { if (user == "" && sys == "") print "0.00"; else printf "%.2f", user + sys }'"'"')
|
||||
mem_total=$(sysctl -n hw.memsize 2>/dev/null || echo 0)
|
||||
pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)
|
||||
vm_output=$(vm_stat 2>/dev/null)
|
||||
pages_active=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages active/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
pages_wired=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages wired down/ { gsub("\\.","",$4); print $4+0 }'"'"')
|
||||
pages_compressed=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages occupied by compressor/ { gsub("\\.","",$5); print $5+0 }'"'"')
|
||||
pages_speculative=$(printf "%s\n" "$vm_output" | awk '"'"'/Pages speculative/ { gsub("\\.","",$3); print $3+0 }'"'"')
|
||||
mem_used=$(( (${pages_active:-0} + ${pages_wired:-0} + ${pages_compressed:-0} + ${pages_speculative:-0}) * ${pagesize:-4096} ))
|
||||
swap_line=$(sysctl vm.swapusage 2>/dev/null || true)
|
||||
swap_used=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ { value=$4; unit=substr(value, length(value), 1); sub(/[A-Za-z]+$/, "", value); printf "%.0f", value * mult(unit) }'"'"')
|
||||
swap_total=$(printf "%s\n" "$swap_line" | awk -F"[= ,]+" '"'"'
|
||||
function mult(unit) { return unit=="K"?1024:(unit=="M"?1048576:(unit=="G"?1073741824:(unit=="T"?1099511627776:1))) }
|
||||
/used/ && /free/ { used=$4; free=$8; unit1=substr(used, length(used), 1); unit2=substr(free, length(free), 1); sub(/[A-Za-z]+$/, "", used); sub(/[A-Za-z]+$/, "", free); printf "%.0f", (used * mult(unit1)) + (free * mult(unit2)) }'"'"')
|
||||
|
||||
echo "CPU_PERCENT=${cpu_percent:-0.00}"
|
||||
echo "MEM_TOTAL=${mem_total:-0}"
|
||||
echo "MEM_USED=${mem_used:-0}"
|
||||
echo "SWAP_TOTAL=${swap_total:-0}"
|
||||
echo "SWAP_USED=${swap_used:-0}"
|
||||
echo "NET_RX=$(( ${net_rx_2:-0} - ${net_rx_1:-0} ))"
|
||||
echo "NET_TX=$(( ${net_tx_2:-0} - ${net_tx_1:-0} ))"
|
||||
df -kP 2>/dev/null | awk "NR > 1 { printf \"DISK=%s\t%s\t%s\n\", \$6, \$4 * 1024, \$2 * 1024 }" | head -n 4
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "CPU_PERCENT=0.00"
|
||||
echo "MEM_TOTAL=0"
|
||||
echo "MEM_USED=0"
|
||||
echo "SWAP_TOTAL=0"
|
||||
echo "SWAP_USED=0"
|
||||
echo "NET_RX=0"
|
||||
echo "NET_TX=0"
|
||||
'"#;
|
||||
|
||||
struct ClientHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl Handler for ClientHandler {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
async fn check_server_key(
|
||||
&mut self,
|
||||
_server_public_key: &russh::keys::ssh_key::PublicKey,
|
||||
) -> Result<bool, Self::Error> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use argon2::Argon2;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chacha20poly1305::{
|
||||
XChaCha20Poly1305, XNonce,
|
||||
aead::{Aead, KeyInit},
|
||||
};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use reqwest::{Client, StatusCode, header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::session::config::Session;
|
||||
|
||||
const SYNC_FILE_NAME: &str = "ashell-sync.json";
|
||||
const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SyncPayload {
|
||||
pub schema_version: u32,
|
||||
pub revision: String,
|
||||
pub updated_at: String,
|
||||
pub device_id: String,
|
||||
pub sessions: Vec<Session>,
|
||||
}
|
||||
|
||||
impl SyncPayload {
|
||||
pub fn new(device_id: String, sessions: Vec<Session>) -> Self {
|
||||
Self {
|
||||
schema_version: FORMAT_VERSION,
|
||||
revision: Uuid::new_v4().to_string(),
|
||||
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||
device_id,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct EncryptedEnvelope {
|
||||
format_version: u32,
|
||||
kdf: String,
|
||||
cipher: String,
|
||||
salt: String,
|
||||
nonce: String,
|
||||
payload: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncCredentials {
|
||||
pub backend: SyncBackendCredentials,
|
||||
pub encryption_password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum SyncBackendCredentials {
|
||||
WebDav {
|
||||
endpoint: String,
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
S3 {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncResult {
|
||||
Uploaded {
|
||||
etag: Option<String>,
|
||||
},
|
||||
Downloaded {
|
||||
payload: SyncPayload,
|
||||
etag: Option<String>,
|
||||
},
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
credentials: SyncCredentials,
|
||||
payload: SyncPayload,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
validate_credentials(&credentials)?;
|
||||
let body = encrypt_payload(&payload, &credentials.encryption_password)?;
|
||||
match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => upload_webdav(&endpoint, &username, &password, body, expected_etag).await,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
upload_s3(&config, body, expected_etag).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn upload_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let client = Client::new();
|
||||
let mut request = client
|
||||
.put(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
request = if let Some(etag) = expected_etag {
|
||||
request.header(header::IF_MATCH, etag)
|
||||
} else {
|
||||
// An uninitialized client may only create a new remote file. This keeps
|
||||
// it from silently replacing configuration uploaded by another device.
|
||||
request.header(header::IF_NONE_MATCH, "*")
|
||||
};
|
||||
let response = request.send().await.context("send WebDAV upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("WebDAV upload failed: HTTP {}", response.status()));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
pub async fn download(credentials: SyncCredentials) -> Result<(SyncPayload, Option<String>)> {
|
||||
validate_credentials(&credentials)?;
|
||||
let encryption_password = credentials.encryption_password;
|
||||
let (body, etag) = match credentials.backend {
|
||||
SyncBackendCredentials::WebDav {
|
||||
endpoint,
|
||||
username,
|
||||
password,
|
||||
} => download_webdav(&endpoint, &username, &password).await?,
|
||||
SyncBackendCredentials::S3 {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
} => {
|
||||
let config = S3Config {
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
object_key,
|
||||
access_key,
|
||||
secret_key,
|
||||
session_token,
|
||||
};
|
||||
download_s3(&config).await?
|
||||
}
|
||||
};
|
||||
let payload = decrypt_payload(&body, &encryption_password)?;
|
||||
Ok((payload, etag))
|
||||
}
|
||||
|
||||
async fn download_webdav(
|
||||
endpoint: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let response = Client::new()
|
||||
.get(sync_url(endpoint))
|
||||
.basic_auth(username, Some(password))
|
||||
.send()
|
||||
.await
|
||||
.context("send WebDAV download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"WebDAV download failed: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("read WebDAV response")?
|
||||
.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn validate_credentials(credentials: &SyncCredentials) -> Result<()> {
|
||||
if credentials.encryption_password.len() < 8 {
|
||||
return Err(anyhow!(
|
||||
"encryption password must contain at least 8 characters"
|
||||
));
|
||||
}
|
||||
match &credentials.backend {
|
||||
SyncBackendCredentials::WebDav { endpoint, .. } if endpoint.trim().is_empty() => {
|
||||
Err(anyhow!("WebDAV endpoint is required"))
|
||||
}
|
||||
SyncBackendCredentials::S3 {
|
||||
region,
|
||||
bucket,
|
||||
access_key,
|
||||
secret_key,
|
||||
..
|
||||
} if region.trim().is_empty()
|
||||
|| bucket.trim().is_empty()
|
||||
|| access_key.trim().is_empty()
|
||||
|| secret_key.is_empty() =>
|
||||
{
|
||||
Err(anyhow!(
|
||||
"S3 region, bucket, access key and secret key are required"
|
||||
))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct S3Config {
|
||||
endpoint: String,
|
||||
region: String,
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
session_token: String,
|
||||
}
|
||||
|
||||
async fn upload_s3(
|
||||
config: &S3Config,
|
||||
body: Vec<u8>,
|
||||
expected_etag: Option<String>,
|
||||
) -> Result<Option<String>> {
|
||||
let url = s3_url(config)?;
|
||||
let mut headers = signed_s3_headers("PUT", &url, &body, config)?;
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
if let Some(etag) = expected_etag {
|
||||
headers.insert(header::IF_MATCH, header_value(&etag, "S3 ETag")?);
|
||||
} else {
|
||||
headers.insert(header::IF_NONE_MATCH, header::HeaderValue::from_static("*"));
|
||||
}
|
||||
let response = Client::new()
|
||||
.put(url)
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 upload")?;
|
||||
if response.status() == StatusCode::PRECONDITION_FAILED
|
||||
|| response.status() == StatusCode::CONFLICT
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"remote configuration changed; download it before uploading"
|
||||
));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 upload failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
Ok(response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string))
|
||||
}
|
||||
|
||||
async fn download_s3(config: &S3Config) -> Result<(Vec<u8>, Option<String>)> {
|
||||
let url = s3_url(config)?;
|
||||
let headers = signed_s3_headers("GET", &url, &[], config)?;
|
||||
let response = Client::new()
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.context("send S3 download")?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Err(anyhow!("no remote configuration exists yet"));
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("S3 download failed: HTTP {status}: {detail}"));
|
||||
}
|
||||
let etag = response
|
||||
.headers()
|
||||
.get(header::ETAG)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response.bytes().await.context("read S3 response")?.to_vec();
|
||||
Ok((body, etag))
|
||||
}
|
||||
|
||||
fn s3_url(config: &S3Config) -> Result<reqwest::Url> {
|
||||
let endpoint = if config.endpoint.trim().is_empty() {
|
||||
format!("https://s3.{}.amazonaws.com", config.region.trim())
|
||||
} else {
|
||||
config.endpoint.trim().trim_end_matches('/').to_string()
|
||||
};
|
||||
let key = if config.object_key.trim().is_empty() {
|
||||
SYNC_FILE_NAME
|
||||
} else {
|
||||
config.object_key.trim().trim_start_matches('/')
|
||||
};
|
||||
let url = format!(
|
||||
"{}/{}/{}",
|
||||
endpoint,
|
||||
aws_uri_encode(config.bucket.trim(), true),
|
||||
aws_uri_encode(key, false)
|
||||
);
|
||||
reqwest::Url::parse(&url).context("parse S3 object URL")
|
||||
}
|
||||
|
||||
fn signed_s3_headers(
|
||||
method: &str,
|
||||
url: &reqwest::Url,
|
||||
body: &[u8],
|
||||
config: &S3Config,
|
||||
) -> Result<header::HeaderMap> {
|
||||
let now = chrono::Utc::now();
|
||||
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let date = now.format("%Y%m%d").to_string();
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow!("S3 endpoint has no host"))?;
|
||||
let host = match url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
let payload_hash = hex_sha256(body);
|
||||
let mut canonical_headers =
|
||||
format!("host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n");
|
||||
let mut signed_headers = "host;x-amz-content-sha256;x-amz-date".to_string();
|
||||
if !config.session_token.is_empty() {
|
||||
canonical_headers.push_str(&format!(
|
||||
"x-amz-security-token:{}\n",
|
||||
config.session_token.trim()
|
||||
));
|
||||
signed_headers.push_str(";x-amz-security-token");
|
||||
}
|
||||
let canonical_request = format!(
|
||||
"{method}\n{}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}",
|
||||
url.path()
|
||||
);
|
||||
let scope = format!("{date}/{}/s3/aws4_request", config.region.trim());
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
|
||||
hex_sha256(canonical_request.as_bytes())
|
||||
);
|
||||
let date_key = hmac_sha256(
|
||||
format!("AWS4{}", config.secret_key).as_bytes(),
|
||||
date.as_bytes(),
|
||||
)?;
|
||||
let region_key = hmac_sha256(&date_key, config.region.trim().as_bytes())?;
|
||||
let service_key = hmac_sha256(®ion_key, b"s3")?;
|
||||
let signing_key = hmac_sha256(&service_key, b"aws4_request")?;
|
||||
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?);
|
||||
let authorization = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}",
|
||||
config.access_key.trim()
|
||||
);
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert("x-amz-date", header_value(&amz_date, "S3 date")?);
|
||||
headers.insert(
|
||||
"x-amz-content-sha256",
|
||||
header_value(&payload_hash, "S3 payload hash")?,
|
||||
);
|
||||
headers.insert(
|
||||
header::AUTHORIZATION,
|
||||
header_value(&authorization, "S3 authorization")?,
|
||||
);
|
||||
if !config.session_token.is_empty() {
|
||||
headers.insert(
|
||||
"x-amz-security-token",
|
||||
header_value(config.session_token.trim(), "S3 session token")?,
|
||||
);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn header_value(value: &str, name: &str) -> Result<header::HeaderValue> {
|
||||
header::HeaderValue::from_str(value).with_context(|| format!("invalid {name}"))
|
||||
}
|
||||
|
||||
fn hmac_sha256(key: &[u8], value: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut mac =
|
||||
<Hmac<Sha256> as Mac>::new_from_slice(key).map_err(|_| anyhow!("initialize S3 signer"))?;
|
||||
mac.update(value);
|
||||
Ok(mac.finalize().into_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn hex_sha256(value: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(value))
|
||||
}
|
||||
|
||||
fn aws_uri_encode(value: &str, encode_slash: bool) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric()
|
||||
|| matches!(byte, b'-' | b'_' | b'.' | b'~')
|
||||
|| (!encode_slash && byte == b'/')
|
||||
{
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn sync_url(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.ends_with('/') {
|
||||
format!("{endpoint}{SYNC_FILE_NAME}")
|
||||
} else if endpoint.ends_with(".json") {
|
||||
endpoint.to_string()
|
||||
} else {
|
||||
format!("{endpoint}/{SYNC_FILE_NAME}")
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_payload(payload: &SyncPayload, password: &str) -> Result<Vec<u8>> {
|
||||
let mut salt = [0u8; 16];
|
||||
let mut nonce = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let key = derive_key(password, &salt)?;
|
||||
let plaintext = serde_json::to_vec(payload).context("serialize sync payload")?;
|
||||
let ciphertext = XChaCha20Poly1305::new((&key).into())
|
||||
.encrypt(XNonce::from_slice(&nonce), plaintext.as_ref())
|
||||
.map_err(|_| anyhow!("encrypt sync payload"))?;
|
||||
serde_json::to_vec_pretty(&EncryptedEnvelope {
|
||||
format_version: FORMAT_VERSION,
|
||||
kdf: "argon2id".to_string(),
|
||||
cipher: "xchacha20poly1305".to_string(),
|
||||
salt: STANDARD.encode(salt),
|
||||
nonce: STANDARD.encode(nonce),
|
||||
payload: STANDARD.encode(ciphertext),
|
||||
})
|
||||
.context("serialize encrypted sync envelope")
|
||||
}
|
||||
|
||||
fn decrypt_payload(raw: &[u8], password: &str) -> Result<SyncPayload> {
|
||||
let envelope: EncryptedEnvelope =
|
||||
serde_json::from_slice(raw).context("parse encrypted sync envelope")?;
|
||||
if envelope.format_version != FORMAT_VERSION
|
||||
|| envelope.kdf != "argon2id"
|
||||
|| envelope.cipher != "xchacha20poly1305"
|
||||
{
|
||||
return Err(anyhow!("unsupported remote sync format"));
|
||||
}
|
||||
let salt = STANDARD.decode(envelope.salt).context("decode sync salt")?;
|
||||
let nonce = STANDARD
|
||||
.decode(envelope.nonce)
|
||||
.context("decode sync nonce")?;
|
||||
if nonce.len() != 24 {
|
||||
return Err(anyhow!("invalid sync nonce"));
|
||||
}
|
||||
let ciphertext = STANDARD
|
||||
.decode(envelope.payload)
|
||||
.context("decode encrypted sync payload")?;
|
||||
let key = derive_key(password, &salt)?;
|
||||
let plaintext = XChaCha20Poly1305::new((&key).into())
|
||||
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| anyhow!("cannot decrypt remote configuration; check the password"))?;
|
||||
let payload: SyncPayload =
|
||||
serde_json::from_slice(&plaintext).context("parse decrypted sync payload")?;
|
||||
if payload.schema_version != FORMAT_VERSION {
|
||||
return Err(anyhow!("unsupported synchronized configuration version"));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn derive_key(password: &str, salt: &[u8]) -> Result<[u8; 32]> {
|
||||
let mut key = [0u8; 32];
|
||||
Argon2::default()
|
||||
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||
.map_err(|err| anyhow!("derive encryption key: {err}"))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypted_payload_round_trip() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(!String::from_utf8_lossy(&encrypted).contains("test-device"));
|
||||
let decrypted = decrypt_payload(&encrypted, "correct horse battery staple").unwrap();
|
||||
assert_eq!(decrypted.revision, payload.revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_password_is_rejected() {
|
||||
let payload = SyncPayload::new("test-device".into(), Vec::new());
|
||||
let encrypted = encrypt_payload(&payload, "correct horse battery staple").unwrap();
|
||||
assert!(decrypt_payload(&encrypted, "incorrect password").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_can_be_a_collection_or_file() {
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/dav/"),
|
||||
"https://example.test/dav/ashell-sync.json"
|
||||
);
|
||||
assert_eq!(
|
||||
sync_url("https://example.test/config.json"),
|
||||
"https://example.test/config.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_url_uses_path_style_and_encodes_object_key() {
|
||||
let config = S3Config {
|
||||
endpoint: "https://s3.example.test".into(),
|
||||
region: "us-east-1".into(),
|
||||
bucket: "my-bucket".into(),
|
||||
object_key: "configs/my file.json".into(),
|
||||
access_key: "access".into(),
|
||||
secret_key: "secret".into(),
|
||||
session_token: String::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
s3_url(&config).unwrap().as_str(),
|
||||
"https://s3.example.test/my-bucket/configs/my%20file.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aws_uri_encoding_preserves_only_object_key_slashes() {
|
||||
assert_eq!(aws_uri_encode("a b/c", false), "a%20b/c");
|
||||
assert_eq!(aws_uri_encode("a/b", true), "a%2Fb");
|
||||
}
|
||||
}
|
||||
-197
@@ -1,197 +0,0 @@
|
||||
use std::{collections::BTreeMap, time::{Duration, Instant}};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use sysinfo::{Disks, Networks, System};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskSample {
|
||||
pub mount: String,
|
||||
pub available_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SystemSnapshot {
|
||||
pub cpu_percent: f32,
|
||||
pub mem_percent: f32,
|
||||
pub swap_percent: f32,
|
||||
pub mem_detail: String,
|
||||
pub swap_detail: String,
|
||||
pub net_rx: String,
|
||||
pub net_tx: String,
|
||||
pub disks: Vec<DiskSample>,
|
||||
}
|
||||
|
||||
pub struct SystemSampler {
|
||||
sys: System,
|
||||
nets: Networks,
|
||||
disks: Disks,
|
||||
last_rx_total: u64,
|
||||
last_tx_total: u64,
|
||||
last_instant: Instant,
|
||||
}
|
||||
|
||||
impl SystemSampler {
|
||||
pub fn new() -> Self {
|
||||
let mut sys = System::new_all();
|
||||
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();
|
||||
|
||||
Self {
|
||||
sys,
|
||||
nets,
|
||||
disks,
|
||||
last_rx_total,
|
||||
last_tx_total,
|
||||
last_instant: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn interval() -> Duration {
|
||||
Duration::from_millis(1000)
|
||||
}
|
||||
|
||||
pub fn sample(&mut self) -> SystemSnapshot {
|
||||
self.sys.refresh_cpu_usage();
|
||||
self.sys.refresh_memory();
|
||||
self.nets.refresh(true);
|
||||
self.disks.refresh(true);
|
||||
|
||||
let cpu_percent = self.sys.global_cpu_usage() / 100.0;
|
||||
let mem_total = self.sys.total_memory();
|
||||
let mem_used = self.sys.used_memory();
|
||||
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 now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_instant).as_secs_f64().max(0.001);
|
||||
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
|
||||
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
|
||||
self.last_rx_total = rx_total;
|
||||
self.last_tx_total = tx_total;
|
||||
self.last_instant = now;
|
||||
|
||||
let disks = self
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|disk| disk.total_space() > 0)
|
||||
.take(4)
|
||||
.map(|disk| DiskSample {
|
||||
mount: disk.mount_point().to_string_lossy().to_string(),
|
||||
available_bytes: disk.available_space(),
|
||||
total_bytes: disk.total_space(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
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)),
|
||||
disks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio(used: u64, total: u64) -> f32 {
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used as f32 / total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} {}", UNITS[unit])
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||
let mut kv = BTreeMap::new();
|
||||
let mut disks = Vec::new();
|
||||
|
||||
for line in raw.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||
if let Some(rest) = line.strip_prefix("DISK=") {
|
||||
let mut parts = rest.split('\t');
|
||||
let mount = parts.next().unwrap_or_default().to_string();
|
||||
let available_bytes = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse::<u64>()
|
||||
.unwrap_or_default();
|
||||
let total_bytes = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse::<u64>()
|
||||
.unwrap_or_default();
|
||||
disks.push(DiskSample {
|
||||
mount,
|
||||
available_bytes,
|
||||
total_bytes,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
kv.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
let cpu_percent = kv
|
||||
.get("CPU_PERCENT")
|
||||
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
|
||||
.parse::<f32>()
|
||||
.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 rx_rate = parse_u64(&kv, "NET_RX");
|
||||
let tx_rate = parse_u64(&kv, "NET_TX");
|
||||
|
||||
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)),
|
||||
disks,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
|
||||
kv.get(key)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
ffi::OsStr,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use sysinfo::{Disks, Networks, System};
|
||||
|
||||
/// Known virtual/ram filesystems to exclude from disk monitoring.
|
||||
fn is_real_filesystem(fs: &OsStr) -> bool {
|
||||
!matches!(
|
||||
fs.to_str(),
|
||||
Some("tmpfs" | "devtmpfs" | "ramfs" | "overlay" | "aufs")
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiskSample {
|
||||
pub mount: String,
|
||||
pub available_bytes: u64,
|
||||
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,
|
||||
pub mem_percent: f32,
|
||||
pub swap_percent: f32,
|
||||
pub mem_detail: String,
|
||||
pub swap_detail: String,
|
||||
pub net_rx: String,
|
||||
pub net_tx: String,
|
||||
pub net_rx_rate: u64,
|
||||
pub net_tx_rate: u64,
|
||||
pub disks: Vec<DiskSample>,
|
||||
pub total_swap: u64,
|
||||
}
|
||||
|
||||
pub struct SystemSampler {
|
||||
sys: System,
|
||||
nets: Networks,
|
||||
disks: Disks,
|
||||
last_rx_total: u64,
|
||||
last_tx_total: u64,
|
||||
last_instant: Instant,
|
||||
}
|
||||
|
||||
impl SystemSampler {
|
||||
pub fn new() -> Self {
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
let nets = Networks::new_with_refreshed_list();
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
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,
|
||||
nets,
|
||||
disks,
|
||||
last_rx_total,
|
||||
last_tx_total,
|
||||
last_instant: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn interval() -> Duration {
|
||||
Duration::from_millis(1000)
|
||||
}
|
||||
|
||||
pub fn sample(&mut self) -> SystemSnapshot {
|
||||
self.sys.refresh_cpu_usage();
|
||||
self.sys.refresh_memory();
|
||||
self.nets.refresh(true);
|
||||
self.disks.refresh(true);
|
||||
|
||||
let cpu_percent = self.sys.global_cpu_usage() / 100.0;
|
||||
let mem_total = self.sys.total_memory();
|
||||
let mem_used = self.sys.used_memory();
|
||||
let swap_total = self.sys.total_swap();
|
||||
let swap_used = self.sys.used_swap();
|
||||
|
||||
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)
|
||||
.as_secs_f64()
|
||||
.max(0.001);
|
||||
let rx_rate = (rx_total.saturating_sub(self.last_rx_total) as f64 / elapsed) as u64;
|
||||
let tx_rate = (tx_total.saturating_sub(self.last_tx_total) as f64 / elapsed) as u64;
|
||||
self.last_rx_total = rx_total;
|
||||
self.last_tx_total = tx_total;
|
||||
self.last_instant = now;
|
||||
|
||||
let mut disks: Vec<DiskSample> = self
|
||||
.disks
|
||||
.iter()
|
||||
.filter(|disk| disk.total_space() > 0 && is_real_filesystem(disk.file_system()))
|
||||
.map(|disk| DiskSample {
|
||||
mount: disk.mount_point().to_string_lossy().to_string(),
|
||||
available_bytes: disk.available_space(),
|
||||
total_bytes: disk.total_space(),
|
||||
})
|
||||
.collect();
|
||||
sort_disks(&mut disks);
|
||||
|
||||
SystemSnapshot {
|
||||
cpu_percent,
|
||||
mem_percent: ratio(mem_used, mem_total),
|
||||
swap_percent: ratio(swap_used, swap_total),
|
||||
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,
|
||||
total_swap: swap_total,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio(used: u64, total: u64) -> f32 {
|
||||
if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(used as f32 / total as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut value = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} {}", UNITS[unit])
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
for line in raw.lines().map(str::trim).filter(|line| !line.is_empty()) {
|
||||
if let Some(rest) = line.strip_prefix("DISK=") {
|
||||
let mut parts = rest.split('\t');
|
||||
let mount = parts.next().unwrap_or_default().to_string();
|
||||
let available_bytes = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse::<u64>()
|
||||
.unwrap_or_default();
|
||||
let total_bytes = parts
|
||||
.next()
|
||||
.unwrap_or("0")
|
||||
.parse::<u64>()
|
||||
.unwrap_or_default();
|
||||
disks.push(DiskSample {
|
||||
mount,
|
||||
available_bytes: available_bytes.min(total_bytes),
|
||||
total_bytes,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
kv.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
let cpu_percent = kv
|
||||
.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_total = parse_u64(&kv, "MEM_TOTAL");
|
||||
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");
|
||||
|
||||
// Safety filter: exclude entries with zero/negligible total size
|
||||
// (catches any virtual fs lines that slipped past the script filter)
|
||||
disks.retain(|d| d.total_bytes >= 1024 * 1024);
|
||||
|
||||
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_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,
|
||||
total_swap: swap_total,
|
||||
})
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
-567
@@ -1,567 +0,0 @@
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use alacritty_terminal::{
|
||||
event::{Event, EventListener},
|
||||
grid::{Dimensions, Scroll},
|
||||
index::{Column, Point, Side},
|
||||
selection::{Selection, SelectionRange, SelectionType},
|
||||
term::{
|
||||
cell::Cell,
|
||||
point_to_viewport, viewport_to_point, Config, Term, TermMode,
|
||||
},
|
||||
vte::ansi::{CursorShape, Processor},
|
||||
};
|
||||
use gpui::Keystroke;
|
||||
|
||||
use crate::config::Session;
|
||||
use crate::sftp::{PreviewData, RemoteEntry};
|
||||
use crate::system::SystemSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TabKind {
|
||||
Local,
|
||||
Ssh,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendCommand {
|
||||
Input(Vec<u8>),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
Output { tab_id: String, bytes: Vec<u8> },
|
||||
Status { tab_id: String, text: String },
|
||||
Connected { tab_id: String },
|
||||
SftpEntries { tab_id: String, path: String, entries: Vec<RemoteEntry> },
|
||||
SftpPreview { tab_id: String, preview: PreviewData },
|
||||
SftpStatus { tab_id: String, text: String },
|
||||
RemoteSystem { tab_id: String, snapshot: SystemSnapshot },
|
||||
RemoteSystemUnavailable { tab_id: String, reason: String },
|
||||
Closed { tab_id: String, reason: String },
|
||||
TerminalTitleChanged { tab_id: String, title: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum BackendTx {
|
||||
Local(Sender<BackendCommand>),
|
||||
Ssh(tokio::sync::mpsc::UnboundedSender<BackendCommand>),
|
||||
}
|
||||
|
||||
impl BackendTx {
|
||||
pub fn send(&self, command: BackendCommand) {
|
||||
match self {
|
||||
Self::Local(tx) => {
|
||||
let _ = tx.send(command);
|
||||
}
|
||||
Self::Ssh(tx) => {
|
||||
let _ = tx.send(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerminalTab {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub kind: TabKind,
|
||||
pub status: String,
|
||||
pub connected: bool,
|
||||
pub session: Option<Session>,
|
||||
pub sftp: Option<SftpUiState>,
|
||||
processor: Processor,
|
||||
term: Term<TerminalListener>,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
pub backend: BackendTx,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CursorState {
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
pub shape: CursorShape,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RenderCell {
|
||||
pub row: i32,
|
||||
pub col: i32,
|
||||
pub cell: Cell,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RenderSnapshot {
|
||||
pub cells: Vec<RenderCell>,
|
||||
pub cursor: Option<CursorState>,
|
||||
pub selection: Option<ViewportSelection>,
|
||||
pub display_offset: usize,
|
||||
pub history_size: usize,
|
||||
pub rows: usize,
|
||||
pub cols: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ViewportSelection {
|
||||
pub start_row: usize,
|
||||
pub start_col: usize,
|
||||
pub end_row: usize,
|
||||
pub end_col: usize,
|
||||
pub is_block: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SftpUiState {
|
||||
pub current_path: String,
|
||||
pub status: String,
|
||||
pub entries: Vec<RemoteEntry>,
|
||||
pub selected_path: Option<String>,
|
||||
pub preview: Option<PreviewData>,
|
||||
}
|
||||
|
||||
impl TerminalTab {
|
||||
pub fn new_local(id: String, title: String, backend: BackendTx, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
|
||||
Self::new(id, title, TabKind::Local, "local shell".into(), backend, events)
|
||||
}
|
||||
|
||||
pub fn new_ssh(id: String, session: &Session, backend: BackendTx, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
|
||||
let mut tab = Self::new(
|
||||
id,
|
||||
session.name.clone(),
|
||||
TabKind::Ssh,
|
||||
format!("connecting {}@{}:{}", session.user, session.host, session.port),
|
||||
backend,
|
||||
events,
|
||||
);
|
||||
tab.session = Some(session.clone());
|
||||
tab.connected = false;
|
||||
tab.sftp = Some(SftpUiState {
|
||||
current_path: "/".into(),
|
||||
status: "sftp connecting...".into(),
|
||||
entries: Vec::new(),
|
||||
selected_path: None,
|
||||
preview: None,
|
||||
});
|
||||
tab
|
||||
}
|
||||
|
||||
fn new(id: String, title: String, kind: TabKind, status: String, backend: BackendTx, events: std::sync::mpsc::Sender<BackendEvent>) -> Self {
|
||||
Self {
|
||||
id: id.clone(),
|
||||
title,
|
||||
kind,
|
||||
status,
|
||||
connected: matches!(kind, TabKind::Local),
|
||||
session: None,
|
||||
sftp: None,
|
||||
processor: Processor::new(),
|
||||
term: new_term(100, 30, backend.clone(), id, events),
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
backend,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn feed(&mut self, bytes: &[u8]) {
|
||||
self.processor.advance(&mut self.term, bytes);
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, cols: u16, rows: u16) {
|
||||
self.cols = cols.max(1);
|
||||
self.rows = rows.max(1);
|
||||
self.term.resize(TerminalSize::new(self.cols, self.rows));
|
||||
self.backend.send(BackendCommand::Resize { cols, rows });
|
||||
}
|
||||
|
||||
pub fn cursor_state(&self) -> Option<CursorState> {
|
||||
let content = self.term.renderable_content();
|
||||
if matches!(content.cursor.shape, CursorShape::Hidden) || content.display_offset > 0 {
|
||||
return None;
|
||||
}
|
||||
let row = content.cursor.point.line.0;
|
||||
if row < 0 {
|
||||
return None;
|
||||
}
|
||||
let row = row as usize;
|
||||
if row >= self.rows as usize {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(CursorState {
|
||||
row,
|
||||
col: content.cursor.point.column.0,
|
||||
shape: content.cursor.shape,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn app_cursor_mode(&self) -> bool {
|
||||
self.term.mode().contains(TermMode::APP_CURSOR)
|
||||
}
|
||||
|
||||
pub fn render_snapshot(&self) -> RenderSnapshot {
|
||||
let rows = self.rows;
|
||||
let cols = self.cols;
|
||||
let content = self.term.renderable_content();
|
||||
let display_offset = content.display_offset as i32;
|
||||
let mut cells = Vec::with_capacity((rows as usize) * (cols as usize));
|
||||
|
||||
for indexed in content.display_iter {
|
||||
let line = indexed.point.line.0;
|
||||
let row = line + display_offset;
|
||||
if row < 0 {
|
||||
continue;
|
||||
}
|
||||
if row >= rows as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let col = indexed.point.column.0 as i32;
|
||||
if col >= cols as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
cells.push(RenderCell {
|
||||
row,
|
||||
col,
|
||||
cell: indexed.cell.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
RenderSnapshot {
|
||||
cells,
|
||||
cursor: self.cursor_state(),
|
||||
selection: viewport_selection_from_range(content.display_offset, &content.selection),
|
||||
display_offset: content.display_offset,
|
||||
history_size: self.term.grid().history_size(),
|
||||
rows: self.rows as usize,
|
||||
cols: self.cols as usize,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_history(&mut self, delta: i32) {
|
||||
if delta != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(delta));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_up_by(&mut self, lines: usize) {
|
||||
if lines != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(lines as i32));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_down_by(&mut self, lines: usize) {
|
||||
if lines != 0 {
|
||||
self.term.scroll_display(Scroll::Delta(-(lines as i32)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.term.scroll_display(Scroll::Bottom);
|
||||
}
|
||||
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.term.selection_to_string().is_some_and(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.term.selection = None;
|
||||
}
|
||||
|
||||
pub fn selection_text(&self) -> Option<String> {
|
||||
self.term.selection_to_string().filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub fn begin_selection(
|
||||
&mut self,
|
||||
row: usize,
|
||||
col: usize,
|
||||
side: Side,
|
||||
selection_type: SelectionType,
|
||||
) {
|
||||
let point = viewport_to_point(
|
||||
self.term.grid().display_offset(),
|
||||
Point::new(row, Column(col)),
|
||||
);
|
||||
self.term.selection = Some(Selection::new(selection_type, point, side));
|
||||
}
|
||||
|
||||
pub fn update_selection(&mut self, row: usize, col: usize, side: Side) {
|
||||
let point = viewport_to_point(
|
||||
self.term.grid().display_offset(),
|
||||
Point::new(row, Column(col)),
|
||||
);
|
||||
if let Some(selection) = self.term.selection.as_mut() {
|
||||
selection.update(point, side);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paste_text(&mut self, text: &str) {
|
||||
let paste_text = text.replace('\x1b', "").replace("\r\n", "\r").replace('\n', "\r");
|
||||
|
||||
self.backend
|
||||
.send(BackendCommand::Input(paste_text.into_bytes()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn viewport_selection_from_range(
|
||||
display_offset: usize,
|
||||
selection: &Option<SelectionRange>,
|
||||
) -> Option<ViewportSelection> {
|
||||
let SelectionRange { start, end, is_block } = selection.as_ref().copied()?;
|
||||
let start = point_to_viewport(display_offset, start)?;
|
||||
let end = point_to_viewport(display_offset, end)?;
|
||||
|
||||
Some(ViewportSelection {
|
||||
start_row: start.line,
|
||||
start_col: start.column.0,
|
||||
end_row: end.line,
|
||||
end_col: end.column.0,
|
||||
is_block,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TerminalListener {
|
||||
tab_id: String,
|
||||
backend: BackendTx,
|
||||
events: std::sync::mpsc::Sender<BackendEvent>,
|
||||
}
|
||||
|
||||
impl EventListener for TerminalListener {
|
||||
fn send_event(&self, event: Event) {
|
||||
match event {
|
||||
Event::PtyWrite(output) => self.backend.send(BackendCommand::Input(output.into_bytes())),
|
||||
Event::TextAreaSizeRequest(format) => {
|
||||
let size = alacritty_terminal::event::WindowSize {
|
||||
num_lines: 30,
|
||||
num_cols: 100,
|
||||
cell_width: 8,
|
||||
cell_height: 16,
|
||||
};
|
||||
self.backend
|
||||
.send(BackendCommand::Input(format(size).into_bytes()));
|
||||
}
|
||||
Event::Title(title) => {
|
||||
let _ = self.events.send(BackendEvent::TerminalTitleChanged {
|
||||
tab_id: self.tab_id.clone(),
|
||||
title,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new_term(cols: u16, rows: u16, backend: BackendTx, tab_id: String, events: std::sync::mpsc::Sender<BackendEvent>) -> Term<TerminalListener> {
|
||||
Term::new(
|
||||
Config {
|
||||
scrolling_history: 2000,
|
||||
..Config::default()
|
||||
},
|
||||
&TerminalSize::new(cols, rows),
|
||||
TerminalListener { tab_id, backend, events },
|
||||
)
|
||||
}
|
||||
|
||||
struct TerminalSize {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl TerminalSize {
|
||||
fn new(cols: u16, rows: u16) -> Self {
|
||||
Self {
|
||||
cols: cols.max(1) as usize,
|
||||
rows: rows.max(1) as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dimensions for TerminalSize {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
|
||||
fn columns(&self) -> usize {
|
||||
self.cols
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_key(keystroke: &Keystroke, app_cursor_mode: bool, option_as_meta: bool) -> Option<Vec<u8>> {
|
||||
zed_like_to_esc_str(keystroke, app_cursor_mode, option_as_meta)
|
||||
.map(|text| text.into_owned().into_bytes())
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum TerminalModifiers {
|
||||
None,
|
||||
Alt,
|
||||
Ctrl,
|
||||
Shift,
|
||||
CtrlShift,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl TerminalModifiers {
|
||||
fn new(ks: &Keystroke) -> Self {
|
||||
match (
|
||||
ks.modifiers.alt,
|
||||
ks.modifiers.control,
|
||||
ks.modifiers.shift,
|
||||
ks.modifiers.platform,
|
||||
) {
|
||||
(false, false, false, false) => Self::None,
|
||||
(true, false, false, false) => Self::Alt,
|
||||
(false, true, false, false) => Self::Ctrl,
|
||||
(false, false, true, false) => Self::Shift,
|
||||
(false, true, true, false) => Self::CtrlShift,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn any(&self) -> bool {
|
||||
!matches!(self, Self::None)
|
||||
}
|
||||
}
|
||||
|
||||
fn zed_like_to_esc_str(
|
||||
keystroke: &Keystroke,
|
||||
app_cursor_mode: bool,
|
||||
option_as_meta: bool,
|
||||
) -> Option<std::borrow::Cow<'static, str>> {
|
||||
let modifiers = TerminalModifiers::new(keystroke);
|
||||
let key = keystroke.key.to_ascii_lowercase();
|
||||
|
||||
let manual_esc_str = match (key.as_str(), &modifiers) {
|
||||
("tab", TerminalModifiers::None) => Some("\x09"),
|
||||
("tab", TerminalModifiers::Shift) => Some("\x1b[Z"),
|
||||
("escape", TerminalModifiers::None) => Some("\x1b"),
|
||||
("enter", TerminalModifiers::None) => Some("\x0d"),
|
||||
("enter", TerminalModifiers::Shift) => Some("\x0a"),
|
||||
("enter", TerminalModifiers::Alt) => Some("\x1b\x0d"),
|
||||
("backspace", TerminalModifiers::None) => Some("\x7f"),
|
||||
("backspace", TerminalModifiers::Ctrl) => Some("\x08"),
|
||||
("backspace", TerminalModifiers::Alt) => Some("\x1b\x7f"),
|
||||
("backspace", TerminalModifiers::Shift) => Some("\x7f"),
|
||||
("space", TerminalModifiers::Ctrl) => Some("\x00"),
|
||||
("home", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOH"),
|
||||
("home", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[H"),
|
||||
("end", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOF"),
|
||||
("end", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[F"),
|
||||
("up", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOA"),
|
||||
("up", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[A"),
|
||||
("down", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOB"),
|
||||
("down", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[B"),
|
||||
("right", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOC"),
|
||||
("right", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[C"),
|
||||
("left", TerminalModifiers::None) if app_cursor_mode => Some("\x1bOD"),
|
||||
("left", TerminalModifiers::None) if !app_cursor_mode => Some("\x1b[D"),
|
||||
("insert", TerminalModifiers::None) => Some("\x1b[2~"),
|
||||
("delete", TerminalModifiers::None) => Some("\x1b[3~"),
|
||||
("pageup", TerminalModifiers::None) => Some("\x1b[5~"),
|
||||
("pagedown", TerminalModifiers::None) => Some("\x1b[6~"),
|
||||
("a", TerminalModifiers::Ctrl) | ("A", TerminalModifiers::CtrlShift) => Some("\x01"),
|
||||
("b", TerminalModifiers::Ctrl) | ("B", TerminalModifiers::CtrlShift) => Some("\x02"),
|
||||
("c", TerminalModifiers::Ctrl) | ("C", TerminalModifiers::CtrlShift) => Some("\x03"),
|
||||
("d", TerminalModifiers::Ctrl) | ("D", TerminalModifiers::CtrlShift) => Some("\x04"),
|
||||
("e", TerminalModifiers::Ctrl) | ("E", TerminalModifiers::CtrlShift) => Some("\x05"),
|
||||
("f", TerminalModifiers::Ctrl) | ("F", TerminalModifiers::CtrlShift) => Some("\x06"),
|
||||
("g", TerminalModifiers::Ctrl) | ("G", TerminalModifiers::CtrlShift) => Some("\x07"),
|
||||
("h", TerminalModifiers::Ctrl) | ("H", TerminalModifiers::CtrlShift) => Some("\x08"),
|
||||
("i", TerminalModifiers::Ctrl) | ("I", TerminalModifiers::CtrlShift) => Some("\x09"),
|
||||
("j", TerminalModifiers::Ctrl) | ("J", TerminalModifiers::CtrlShift) => Some("\x0a"),
|
||||
("k", TerminalModifiers::Ctrl) | ("K", TerminalModifiers::CtrlShift) => Some("\x0b"),
|
||||
("l", TerminalModifiers::Ctrl) | ("L", TerminalModifiers::CtrlShift) => Some("\x0c"),
|
||||
("m", TerminalModifiers::Ctrl) | ("M", TerminalModifiers::CtrlShift) => Some("\x0d"),
|
||||
("n", TerminalModifiers::Ctrl) | ("N", TerminalModifiers::CtrlShift) => Some("\x0e"),
|
||||
("o", TerminalModifiers::Ctrl) | ("O", TerminalModifiers::CtrlShift) => Some("\x0f"),
|
||||
("p", TerminalModifiers::Ctrl) | ("P", TerminalModifiers::CtrlShift) => Some("\x10"),
|
||||
("q", TerminalModifiers::Ctrl) | ("Q", TerminalModifiers::CtrlShift) => Some("\x11"),
|
||||
("r", TerminalModifiers::Ctrl) | ("R", TerminalModifiers::CtrlShift) => Some("\x12"),
|
||||
("s", TerminalModifiers::Ctrl) | ("S", TerminalModifiers::CtrlShift) => Some("\x13"),
|
||||
("t", TerminalModifiers::Ctrl) | ("T", TerminalModifiers::CtrlShift) => Some("\x14"),
|
||||
("u", TerminalModifiers::Ctrl) | ("U", TerminalModifiers::CtrlShift) => Some("\x15"),
|
||||
("v", TerminalModifiers::Ctrl) | ("V", TerminalModifiers::CtrlShift) => Some("\x16"),
|
||||
("w", TerminalModifiers::Ctrl) | ("W", TerminalModifiers::CtrlShift) => Some("\x17"),
|
||||
("x", TerminalModifiers::Ctrl) | ("X", TerminalModifiers::CtrlShift) => Some("\x18"),
|
||||
("y", TerminalModifiers::Ctrl) | ("Y", TerminalModifiers::CtrlShift) => Some("\x19"),
|
||||
("z", TerminalModifiers::Ctrl) | ("Z", TerminalModifiers::CtrlShift) => Some("\x1a"),
|
||||
("@", TerminalModifiers::Ctrl) => Some("\x00"),
|
||||
("[", TerminalModifiers::Ctrl) => Some("\x1b"),
|
||||
("\\", TerminalModifiers::Ctrl) => Some("\x1c"),
|
||||
("]", TerminalModifiers::Ctrl) => Some("\x1d"),
|
||||
("^", TerminalModifiers::Ctrl) => Some("\x1e"),
|
||||
("_", TerminalModifiers::Ctrl) => Some("\x1f"),
|
||||
("?", TerminalModifiers::Ctrl) => Some("\x7f"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(esc) = manual_esc_str {
|
||||
return Some(esc.into());
|
||||
}
|
||||
|
||||
if modifiers.any() {
|
||||
let modifier_code = modifier_code(keystroke);
|
||||
let modified = match key.as_str() {
|
||||
"up" => Some(format!("\x1b[1;{}A", modifier_code)),
|
||||
"down" => Some(format!("\x1b[1;{}B", modifier_code)),
|
||||
"right" => Some(format!("\x1b[1;{}C", modifier_code)),
|
||||
"left" => Some(format!("\x1b[1;{}D", modifier_code)),
|
||||
"insert" => Some(format!("\x1b[2;{}~", modifier_code)),
|
||||
"pageup" => Some(format!("\x1b[5;{}~", modifier_code)),
|
||||
"pagedown" => Some(format!("\x1b[6;{}~", modifier_code)),
|
||||
"end" => Some(format!("\x1b[1;{}F", modifier_code)),
|
||||
"home" => Some(format!("\x1b[1;{}H", modifier_code)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(esc) = modified {
|
||||
return Some(esc.into());
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "macos") || option_as_meta {
|
||||
let is_alt_lowercase_ascii =
|
||||
modifiers == TerminalModifiers::Alt && keystroke.key.is_ascii();
|
||||
let is_alt_uppercase_ascii =
|
||||
keystroke.modifiers.alt && keystroke.modifiers.shift && keystroke.key.is_ascii();
|
||||
if is_alt_lowercase_ascii || is_alt_uppercase_ascii {
|
||||
let key = if is_alt_uppercase_ascii {
|
||||
keystroke.key.to_ascii_uppercase()
|
||||
} else {
|
||||
keystroke.key.clone()
|
||||
};
|
||||
return Some(format!("\x1b{}", key).into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(text) = &keystroke.key_char {
|
||||
return Some(text.clone().into());
|
||||
}
|
||||
|
||||
if keystroke.key.len() == 1 {
|
||||
return Some(keystroke.key.clone().into());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn modifier_code(keystroke: &Keystroke) -> u32 {
|
||||
let mut modifier_code = 0;
|
||||
if keystroke.modifiers.shift {
|
||||
modifier_code |= 1;
|
||||
}
|
||||
if keystroke.modifiers.alt {
|
||||
modifier_code |= 1 << 1;
|
||||
}
|
||||
if keystroke.modifiers.control {
|
||||
modifier_code |= 1 << 2;
|
||||
}
|
||||
modifier_code + 1
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
use gpui::{Bounds, Hsla, Path, Pixels, Window, fill, point, px, size};
|
||||
|
||||
pub fn is_custom_block_supported(c: char) -> bool {
|
||||
match c as u32 {
|
||||
0x2580..=0x258F | 0x2590 | 0x2594..=0x259F => true, // Block Elements
|
||||
0x2500 | 0x2502 | 0x250C | 0x2510 | 0x2514 | 0x2518 | 0x251C | 0x2524 | 0x252C | 0x2534
|
||||
| 0x253C => true, // Light lines
|
||||
0x2501 | 0x2503 | 0x250F | 0x2513 | 0x2517 | 0x251B | 0x2523 | 0x252B | 0x2533 | 0x253B
|
||||
| 0x254B => true, // Heavy lines
|
||||
0xE0B0..=0xE0B6 => true, // Powerline
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paint_custom_block(
|
||||
window: &mut Window,
|
||||
c: char,
|
||||
raw_x: f32,
|
||||
raw_y: f32,
|
||||
raw_w: f32,
|
||||
raw_h: f32,
|
||||
color: Hsla,
|
||||
) -> bool {
|
||||
let scale = window.scale_factor();
|
||||
|
||||
let x = px((raw_x * scale).round() / scale);
|
||||
let y = px((raw_y * scale).round() / scale);
|
||||
let w = px(((raw_x + raw_w) * scale).round() / scale) - x;
|
||||
let h = px(((raw_y + raw_h) * scale).round() / scale) - y;
|
||||
|
||||
let mut painted = false;
|
||||
|
||||
// Helper to paint a quad snapping its true physical boundaries to integer physical pixels
|
||||
let mut paint_quad = |qx: Pixels, qy: Pixels, qw: Pixels, qh: Pixels| {
|
||||
let l = px((qx.as_f32() * scale).round() / scale);
|
||||
let t = px((qy.as_f32() * scale).round() / scale);
|
||||
let r = px(((qx + qw).as_f32() * scale).round() / scale);
|
||||
let b = px(((qy + qh).as_f32() * scale).round() / scale);
|
||||
window.paint_quad(fill(Bounds::new(point(l, t), size(r - l, b - t)), color));
|
||||
painted = true;
|
||||
};
|
||||
|
||||
let px_1 = px(1.0);
|
||||
let px_2 = px(2.0);
|
||||
|
||||
// Box drawing thickness
|
||||
let light = (w * 0.1).max(px_1);
|
||||
let heavy = (w * 0.2).max(px_2);
|
||||
let cx = x + w / 2.0;
|
||||
let cy = y + h / 2.0;
|
||||
|
||||
match c as u32 {
|
||||
// --- Block Elements (U+2580..=U+259F) ---
|
||||
0x2580 => paint_quad(x, y, w, h / 2.0),
|
||||
0x2581 => paint_quad(x, y + h * 7.0 / 8.0, w, h / 8.0),
|
||||
0x2582 => paint_quad(x, y + h * 3.0 / 4.0, w, h / 4.0),
|
||||
0x2583 => paint_quad(x, y + h * 5.0 / 8.0, w, h * 3.0 / 8.0),
|
||||
0x2584 => paint_quad(x, y + h / 2.0, w, h / 2.0),
|
||||
0x2585 => paint_quad(x, y + h * 3.0 / 8.0, w, h * 5.0 / 8.0),
|
||||
0x2586 => paint_quad(x, y + h / 4.0, w, h * 3.0 / 4.0),
|
||||
0x2587 => paint_quad(x, y + h / 8.0, w, h * 7.0 / 8.0),
|
||||
0x2588 => paint_quad(x, y, w, h),
|
||||
0x2589 => paint_quad(x, y, w * 7.0 / 8.0, h),
|
||||
0x258A => paint_quad(x, y, w * 3.0 / 4.0, h),
|
||||
0x258B => paint_quad(x, y, w * 5.0 / 8.0, h),
|
||||
0x258C => paint_quad(x, y, w / 2.0, h),
|
||||
0x258D => paint_quad(x, y, w * 3.0 / 8.0, h),
|
||||
0x258E => paint_quad(x, y, w / 4.0, h),
|
||||
0x258F => paint_quad(x, y, w / 8.0, h),
|
||||
0x2590 => paint_quad(x + w / 2.0, y, w / 2.0, h),
|
||||
0x2594 => paint_quad(x, y, w, h / 8.0),
|
||||
0x2595 => paint_quad(x + w * 7.0 / 8.0, y, w / 8.0, h),
|
||||
0x2596 => paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0),
|
||||
0x2597 => paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0),
|
||||
0x2598 => paint_quad(x, y, w / 2.0, h / 2.0),
|
||||
0x2599 => {
|
||||
paint_quad(x, y, w / 2.0, h);
|
||||
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259A => {
|
||||
paint_quad(x, y, w / 2.0, h / 2.0);
|
||||
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259B => {
|
||||
paint_quad(x, y, w, h / 2.0);
|
||||
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259C => {
|
||||
paint_quad(x, y, w, h / 2.0);
|
||||
paint_quad(x + w / 2.0, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259D => paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0),
|
||||
0x259E => {
|
||||
paint_quad(x + w / 2.0, y, w / 2.0, h / 2.0);
|
||||
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
0x259F => {
|
||||
paint_quad(x + w / 2.0, y, w / 2.0, h);
|
||||
paint_quad(x, y + h / 2.0, w / 2.0, h / 2.0);
|
||||
}
|
||||
|
||||
// --- Basic Box Drawing (U+2500..=U+257F) ---
|
||||
// Light lines
|
||||
0x2500 => paint_quad(x, cy - light / 2.0, w, light), // Horizontal
|
||||
0x2502 => paint_quad(cx - light / 2.0, y, light, h), // Vertical
|
||||
0x250C => {
|
||||
// Down + Right
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
light,
|
||||
h / 2.0 + light / 2.0,
|
||||
);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
w / 2.0 + light / 2.0,
|
||||
light,
|
||||
);
|
||||
}
|
||||
0x2510 => {
|
||||
// Down + Left
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
light,
|
||||
h / 2.0 + light / 2.0,
|
||||
);
|
||||
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
|
||||
}
|
||||
0x2514 => {
|
||||
// Up + Right
|
||||
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
w / 2.0 + light / 2.0,
|
||||
light,
|
||||
);
|
||||
}
|
||||
0x2518 => {
|
||||
// Up + Left
|
||||
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
|
||||
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
|
||||
}
|
||||
0x251C => {
|
||||
// Vertical + Right
|
||||
paint_quad(cx - light / 2.0, y, light, h);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
w / 2.0 + light / 2.0,
|
||||
light,
|
||||
);
|
||||
}
|
||||
0x2524 => {
|
||||
// Vertical + Left
|
||||
paint_quad(cx - light / 2.0, y, light, h);
|
||||
paint_quad(x, cy - light / 2.0, w / 2.0 + light / 2.0, light);
|
||||
}
|
||||
0x252C => {
|
||||
// Horizontal + Down
|
||||
paint_quad(x, cy - light / 2.0, w, light);
|
||||
paint_quad(
|
||||
cx - light / 2.0,
|
||||
cy - light / 2.0,
|
||||
light,
|
||||
h / 2.0 + light / 2.0,
|
||||
);
|
||||
}
|
||||
0x2534 => {
|
||||
// Horizontal + Up
|
||||
paint_quad(x, cy - light / 2.0, w, light);
|
||||
paint_quad(cx - light / 2.0, y, light, h / 2.0 + light / 2.0);
|
||||
}
|
||||
0x253C => {
|
||||
// Vertical + Horizontal (Cross)
|
||||
paint_quad(x, cy - light / 2.0, w, light);
|
||||
paint_quad(cx - light / 2.0, y, light, h);
|
||||
}
|
||||
|
||||
// Heavy lines
|
||||
0x2501 => paint_quad(x, cy - heavy / 2.0, w, heavy), // Heavy Horizontal
|
||||
0x2503 => paint_quad(cx - heavy / 2.0, y, heavy, h), // Heavy Vertical
|
||||
0x250F => {
|
||||
// Heavy Down + Right
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
heavy,
|
||||
h / 2.0 + heavy / 2.0,
|
||||
);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
w / 2.0 + heavy / 2.0,
|
||||
heavy,
|
||||
);
|
||||
}
|
||||
0x2513 => {
|
||||
// Heavy Down + Left
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
heavy,
|
||||
h / 2.0 + heavy / 2.0,
|
||||
);
|
||||
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
|
||||
}
|
||||
0x2517 => {
|
||||
// Heavy Up + Right
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
w / 2.0 + heavy / 2.0,
|
||||
heavy,
|
||||
);
|
||||
}
|
||||
0x251B => {
|
||||
// Heavy Up + Left
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
|
||||
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
|
||||
}
|
||||
0x2523 => {
|
||||
// Heavy Vertical + Right
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
w / 2.0 + heavy / 2.0,
|
||||
heavy,
|
||||
);
|
||||
}
|
||||
0x252B => {
|
||||
// Heavy Vertical + Left
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h);
|
||||
paint_quad(x, cy - heavy / 2.0, w / 2.0 + heavy / 2.0, heavy);
|
||||
}
|
||||
0x2533 => {
|
||||
// Heavy Horizontal + Down
|
||||
paint_quad(x, cy - heavy / 2.0, w, heavy);
|
||||
paint_quad(
|
||||
cx - heavy / 2.0,
|
||||
cy - heavy / 2.0,
|
||||
heavy,
|
||||
h / 2.0 + heavy / 2.0,
|
||||
);
|
||||
}
|
||||
0x253B => {
|
||||
// Heavy Horizontal + Up
|
||||
paint_quad(x, cy - heavy / 2.0, w, heavy);
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h / 2.0 + heavy / 2.0);
|
||||
}
|
||||
0x254B => {
|
||||
// Heavy Vertical + Horizontal (Cross)
|
||||
paint_quad(x, cy - heavy / 2.0, w, heavy);
|
||||
paint_quad(cx - heavy / 2.0, y, heavy, h);
|
||||
}
|
||||
|
||||
// --- Powerline ---
|
||||
0xE0B0 => {
|
||||
// Rightward Solid Arrow
|
||||
let mut path = Path::new(point(x, y));
|
||||
path.line_to(point(x + w, y + h / 2.0));
|
||||
path.line_to(point(x, y + h));
|
||||
path.line_to(point(x, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B2 => {
|
||||
// Leftward Solid Arrow
|
||||
let mut path = Path::new(point(x + w, y));
|
||||
path.line_to(point(x, y + h / 2.0));
|
||||
path.line_to(point(x + w, y + h));
|
||||
path.line_to(point(x + w, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B1 => {
|
||||
// Rightward Line Arrow
|
||||
let t = px(1.0);
|
||||
let mut p = Path::new(point(x, y));
|
||||
p.line_to(point(x + w, y + h / 2.0));
|
||||
p.line_to(point(x + w - t, y + h / 2.0));
|
||||
p.line_to(point(x, y + t));
|
||||
p.line_to(point(x, y));
|
||||
window.paint_path(p, color);
|
||||
let mut p2 = Path::new(point(x, y + h));
|
||||
p2.line_to(point(x + w, y + h / 2.0));
|
||||
p2.line_to(point(x + w - t, y + h / 2.0));
|
||||
p2.line_to(point(x, y + h - t));
|
||||
p2.line_to(point(x, y + h));
|
||||
window.paint_path(p2, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B3 => {
|
||||
// Leftward Line Arrow
|
||||
let t = px(1.0);
|
||||
let mut p = Path::new(point(x + w, y));
|
||||
p.line_to(point(x, y + h / 2.0));
|
||||
p.line_to(point(x + t, y + h / 2.0));
|
||||
p.line_to(point(x + w, y + t));
|
||||
p.line_to(point(x + w, y));
|
||||
window.paint_path(p, color);
|
||||
let mut p2 = Path::new(point(x + w, y + h));
|
||||
p2.line_to(point(x, y + h / 2.0));
|
||||
p2.line_to(point(x + t, y + h / 2.0));
|
||||
p2.line_to(point(x + w, y + h - t));
|
||||
p2.line_to(point(x + w, y + h));
|
||||
window.paint_path(p2, color);
|
||||
painted = true;
|
||||
}
|
||||
|
||||
// Half arches (Powerline rounded)
|
||||
0xE0B4 => {
|
||||
// Rightward Solid Semicircle
|
||||
let mut path = Path::new(point(x, y));
|
||||
path.curve_to(point(x, y + h), point(x + w * 2.0, y + h / 2.0));
|
||||
path.line_to(point(x, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
0xE0B6 => {
|
||||
// Leftward Solid Semicircle
|
||||
let mut path = Path::new(point(x + w, y));
|
||||
path.curve_to(point(x + w, y + h), point(x - w, y + h / 2.0));
|
||||
path.line_to(point(x + w, y));
|
||||
window.paint_path(path, color);
|
||||
painted = true;
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
|
||||
painted
|
||||
}
|
||||
@@ -3,13 +3,16 @@ use alacritty_terminal::{
|
||||
vte::ansi::{Color as AnsiColor, CursorShape, NamedColor},
|
||||
};
|
||||
use gpui::{
|
||||
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, Hsla, InputHandler, IntoElement,
|
||||
LayoutId, Pixels, Point, Rgba, StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point, px,
|
||||
relative, rgb,
|
||||
App, Bounds, Element, ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight,
|
||||
GlobalElementId, Hsla, InputHandler, IntoElement, LayoutId, Pixels, Point, Rgba, SharedString,
|
||||
StrikethroughStyle, TextRun, TextStyle, UTF16Selection, UnderlineStyle, Window, fill, point,
|
||||
px, relative, rgb,
|
||||
};
|
||||
use gpui_component::ActiveTheme as _;
|
||||
|
||||
use crate::Ashell;
|
||||
use crate::terminal::custom_blocks::{is_custom_block_supported, paint_custom_block};
|
||||
use crate::terminal::highlight::HighlightStyle;
|
||||
use crate::terminal::{RenderSnapshot, ViewportSelection};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -37,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,
|
||||
@@ -81,7 +104,13 @@ impl BatchedTextRun {
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, origin: Point<Pixels>, metrics: TerminalMetrics, window: &mut Window, cx: &mut App) {
|
||||
fn paint(
|
||||
&self,
|
||||
origin: Point<Pixels>,
|
||||
metrics: TerminalMetrics,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let pos = point(
|
||||
origin.x + metrics.cell_width * self.col as f32,
|
||||
origin.y + metrics.line_height * self.row as f32,
|
||||
@@ -118,12 +147,29 @@ struct CursorLayout {
|
||||
pub struct TerminalElement {
|
||||
view: Entity<Ashell>,
|
||||
focus_handle: FocusHandle,
|
||||
pane_focused: bool,
|
||||
snapshot: RenderSnapshot,
|
||||
marked_text: Option<String>,
|
||||
font_family: &'static str,
|
||||
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 {
|
||||
@@ -131,12 +177,25 @@ pub struct PrepaintState {
|
||||
metrics: TerminalMetrics,
|
||||
rects: Vec<LayoutRect>,
|
||||
runs: Vec<BatchedTextRun>,
|
||||
custom_blocks: Vec<LayoutCustomBlock>,
|
||||
cursor: Option<CursorLayout>,
|
||||
underlines: Vec<LayoutUnderline>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LayoutCustomBlock {
|
||||
c: char,
|
||||
row: i32,
|
||||
col: i32,
|
||||
cells: usize,
|
||||
color: Hsla,
|
||||
}
|
||||
|
||||
struct TerminalInputHandler {
|
||||
view: Entity<Ashell>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
cell_width: f32,
|
||||
line_height: f32,
|
||||
}
|
||||
|
||||
impl InputHandler for TerminalInputHandler {
|
||||
@@ -146,10 +205,13 @@ impl InputHandler for TerminalInputHandler {
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<UTF16Selection> {
|
||||
self.view.read(cx).terminal_accepts_text_input().then_some(UTF16Selection {
|
||||
range: 0..0,
|
||||
reversed: false,
|
||||
})
|
||||
self.view
|
||||
.read(cx)
|
||||
.terminal_accepts_text_input()
|
||||
.then_some(UTF16Selection {
|
||||
range: 0..0,
|
||||
reversed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
@@ -207,9 +269,12 @@ impl InputHandler for TerminalInputHandler {
|
||||
_window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
self.view
|
||||
.read(cx)
|
||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds)
|
||||
self.view.read(cx).terminal_ime_bounds_for_range(
|
||||
range_utf16,
|
||||
self.element_bounds,
|
||||
self.cell_width,
|
||||
self.line_height,
|
||||
)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
@@ -235,57 +300,65 @@ impl InputHandler for TerminalInputHandler {
|
||||
}
|
||||
|
||||
impl TerminalElement {
|
||||
pub fn new(
|
||||
view: Entity<Ashell>,
|
||||
focus_handle: FocusHandle,
|
||||
snapshot: RenderSnapshot,
|
||||
marked_text: Option<String>,
|
||||
font_family: &'static str,
|
||||
font_size: Pixels,
|
||||
line_height: Pixels,
|
||||
cell_width: Pixels,
|
||||
) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn base_text_style(&self, cx: &App) -> TextStyle {
|
||||
TextStyle {
|
||||
color: cx.theme().foreground,
|
||||
font_family: self.font_family.into(),
|
||||
font_family: self.font_family.clone(),
|
||||
font_size: self.font_size.into(),
|
||||
line_height: self.line_height.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let underline = cell.flags.intersects(Flags::ALL_UNDERLINES).then(|| UnderlineStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||
});
|
||||
let strikethrough = cell.flags.contains(Flags::STRIKEOUT).then(|| StrikethroughStyle {
|
||||
color: Some(fg),
|
||||
thickness: px(1.0),
|
||||
});
|
||||
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(foreground),
|
||||
thickness: px(1.0),
|
||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||
});
|
||||
let strikethrough = cell
|
||||
.flags
|
||||
.contains(Flags::STRIKEOUT)
|
||||
.then(|| StrikethroughStyle {
|
||||
color: Some(foreground),
|
||||
thickness: px(1.0),
|
||||
});
|
||||
|
||||
let weight = if cell.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) {
|
||||
FontWeight::BOLD
|
||||
@@ -300,10 +373,10 @@ impl TerminalElement {
|
||||
|
||||
TextRun {
|
||||
len: cell.c.len_utf8(),
|
||||
color: fg,
|
||||
color: foreground,
|
||||
background_color: None,
|
||||
font: Font {
|
||||
family: self.font_family.into(),
|
||||
family: self.font_family.clone(),
|
||||
weight,
|
||||
style,
|
||||
..Font::default()
|
||||
@@ -313,9 +386,22 @@ impl TerminalElement {
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_grid(&self, cx: &App) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
|
||||
fn layout_grid(
|
||||
&self,
|
||||
cx: &App,
|
||||
) -> (
|
||||
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;
|
||||
|
||||
for render_cell in &self.snapshot.cells {
|
||||
@@ -326,24 +412,57 @@ impl TerminalElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 selected = self.snapshot.selection.is_some_and(|selection| {
|
||||
selection_contains(selection, render_cell.row, render_cell.col)
|
||||
});
|
||||
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: 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) {
|
||||
@@ -353,7 +472,49 @@ impl TerminalElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
let style = self.cell_run_style(cell, cx);
|
||||
let style = self.cell_run_style(cell, foreground);
|
||||
|
||||
// Apply hover underline if mouse is hovering over this URL
|
||||
if let Some(hu) = &hovered_url {
|
||||
if hu.tab_id == self.tab_id
|
||||
&& hu
|
||||
.cells
|
||||
.contains(&(render_cell.row as usize, render_cell.col as usize))
|
||||
{
|
||||
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
|
||||
let is_custom_block = is_custom_block_supported(cell.c);
|
||||
|
||||
if is_custom_block {
|
||||
if let Some(run) = current_run.take() {
|
||||
runs.push(run);
|
||||
}
|
||||
custom_blocks.push(LayoutCustomBlock {
|
||||
c: cell.c,
|
||||
row: render_cell.row,
|
||||
col: render_cell.col,
|
||||
cells: if cell.flags.contains(Flags::WIDE_CHAR) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
},
|
||||
color: style.color,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(run) = current_run.as_mut() {
|
||||
if run.can_append(&style, render_cell.row, render_cell.col) {
|
||||
run.append(cell.c, cell.zerowidth());
|
||||
@@ -385,19 +546,62 @@ impl TerminalElement {
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
(merge_rects(rects), runs)
|
||||
(
|
||||
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;
|
||||
|
||||
@@ -437,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) = 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 {
|
||||
@@ -450,7 +674,9 @@ impl Element for TerminalElement {
|
||||
},
|
||||
rects,
|
||||
runs,
|
||||
cursor: self.cursor_layout(cx),
|
||||
custom_blocks,
|
||||
cursor: self.cursor_layout(window, cx),
|
||||
underlines,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,19 +690,52 @@ 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 =
|
||||
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,
|
||||
x,
|
||||
y,
|
||||
prepaint.metrics.cell_width.as_f32() * block.cells as f32,
|
||||
prepaint.metrics.line_height.as_f32(),
|
||||
block.color,
|
||||
);
|
||||
}
|
||||
|
||||
window.handle_input(
|
||||
&self.focus_handle,
|
||||
TerminalInputHandler {
|
||||
view: self.view.clone(),
|
||||
element_bounds: prepaint.bounds,
|
||||
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(),
|
||||
},
|
||||
cx,
|
||||
);
|
||||
@@ -484,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 {
|
||||
@@ -499,7 +758,7 @@ impl Element for TerminalElement {
|
||||
&[TextRun {
|
||||
len: marked_text.len(),
|
||||
font: Font {
|
||||
family: self.font_family.into(),
|
||||
family: self.font_family.clone(),
|
||||
..Font::default()
|
||||
},
|
||||
color: base_style.color,
|
||||
@@ -525,16 +784,23 @@ impl Element for TerminalElement {
|
||||
}
|
||||
|
||||
if let Some(cursor) = prepaint.cursor {
|
||||
if self.marked_text.as_ref().is_some_and(|text| !text.is_empty()) {
|
||||
if self
|
||||
.marked_text
|
||||
.as_ref()
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
|
||||
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 => {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(point(x, y), gpui::size(px(2.), prepaint.metrics.line_height)),
|
||||
Bounds::new(
|
||||
point(x, y),
|
||||
gpui::size(px(2.), prepaint.metrics.line_height),
|
||||
),
|
||||
cursor.color,
|
||||
));
|
||||
}
|
||||
@@ -572,7 +838,10 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
|
||||
|
||||
for rect in rects {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.row == rect.row && last.color == rect.color && last.col + last.cells as i32 == rect.col {
|
||||
if last.row == rect.row
|
||||
&& last.color == rect.color
|
||||
&& last.col + last.cells as i32 == rect.col
|
||||
{
|
||||
last.cells += rect.cells;
|
||||
continue;
|
||||
}
|
||||
@@ -583,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;
|
||||
@@ -600,8 +886,37 @@ 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() && !cell.flags.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
|
||||
cell.c == ' '
|
||||
&& cell.zerowidth().is_none()
|
||||
&& !cell
|
||||
.flags
|
||||
.intersects(Flags::ALL_UNDERLINES | Flags::STRIKEOUT)
|
||||
}
|
||||
|
||||
fn is_default_bg(color: AnsiColor) -> bool {
|
||||
@@ -623,8 +938,8 @@ fn color_to_hsla(color: AnsiColor, foreground: bool, cx: &App) -> Hsla {
|
||||
|
||||
fn ansi_index_color(index: u8, _cx: &App) -> Hsla {
|
||||
const ANSI_16: [u32; 16] = [
|
||||
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0,
|
||||
0x686868, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
|
||||
0x1f2430, 0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xf1f1f0, 0x686868,
|
||||
0xff5c57, 0x5af78e, 0xf3f99d, 0x57c7ff, 0xff6ac1, 0x9aedfe, 0xffffff,
|
||||
];
|
||||
|
||||
if (index as usize) < ANSI_16.len() {
|
||||
@@ -687,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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2845
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