mirror of
https://github.com/rust-kotlin/ashell.git
synced 2026-10-03 16:00:18 +00:00
Compare 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 |
@@ -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 }}
|
||||||
+440
-52
@@ -1,42 +1,80 @@
|
|||||||
name: Release
|
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
|
# - Push a tag like `v0.2.3` -> builds all platforms and attaches the archives
|
||||||
# to a GitHub Release.
|
# to a GitHub Release.
|
||||||
# - Run manually (workflow_dispatch) -> builds all platforms and uploads them as
|
# - Push a `build/**` branch -> builds preview installers as workflow artifacts.
|
||||||
# downloadable workflow artifacts (no release created).
|
# - Run manually (workflow_dispatch) -> builds all platforms by default, or only
|
||||||
|
# the selected target, and uploads them as downloadable workflow artifacts.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: ["build/**"]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
platform:
|
||||||
|
description: "Build target"
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
default: all
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- windows-x64
|
||||||
|
- macos-arm64
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # needed to create / update the GitHub Release
|
contents: write # needed to create / update the GitHub Release
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
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 }}
|
name: ${{ matrix.name }}
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: windows-latest
|
- os: windows-2022
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
name: windows-x86_64
|
name: windows-x64
|
||||||
|
kind: windows
|
||||||
|
arch: x64
|
||||||
bin: ashell.exe
|
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
|
target: x86_64-unknown-linux-gnu
|
||||||
name: linux-x86_64
|
name: linux-x64
|
||||||
|
kind: linux
|
||||||
|
arch: x64
|
||||||
bin: ashell
|
bin: ashell
|
||||||
- os: macos-14 # Apple Silicon
|
- os: macos-14 # Apple Silicon
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
name: macos-aarch64
|
name: macos-arm64
|
||||||
|
kind: macos
|
||||||
|
arch: arm64
|
||||||
|
binary_arch: arm64
|
||||||
bin: ashell
|
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
|
target: x86_64-apple-darwin
|
||||||
name: macos-x86_64
|
name: macos-x64
|
||||||
|
kind: macos
|
||||||
|
arch: x64
|
||||||
|
binary_arch: x86_64
|
||||||
bin: ashell
|
bin: ashell
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -68,28 +106,46 @@ jobs:
|
|||||||
- name: Build (release)
|
- name: Build (release)
|
||||||
run: cargo build --release --target ${{ matrix.target }}
|
run: cargo build --release --target ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Package
|
- name: Set package metadata
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -euo pipefail
|
||||||
VERSION="${GITHUB_REF_NAME:-dev}"
|
VERSION="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n 1)"
|
||||||
STAGE="ashell-${VERSION}-${{ matrix.name }}"
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "Unable to read the package version from Cargo.toml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "${{ runner.os }}" = "macOS" ]; then
|
if [ "$GITHUB_REF_TYPE" = "tag" ]; then
|
||||||
# ── Build a proper .app bundle ────────────────────────────────────
|
PACKAGE_VERSION="${GITHUB_REF_NAME#v}"
|
||||||
# Note: We build the bundle manually here rather than running
|
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||||
# scripts/package-macos-app.sh, because that script runs `cargo build`
|
echo "Tag version $PACKAGE_VERSION does not match Cargo.toml version $VERSION" >&2
|
||||||
# without respecting the `--target` flag from the matrix.
|
exit 1
|
||||||
|
fi
|
||||||
APP="ashell.app"
|
else
|
||||||
CONTENTS="$APP/Contents"
|
PACKAGE_VERSION="${VERSION}-dev.${GITHUB_SHA:0:7}"
|
||||||
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
|
fi
|
||||||
|
|
||||||
cp "target/${{ matrix.target }}/release/ashell" "$CONTENTS/MacOS/"
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
cp "assets/icons/ashell.icns" "$CONTENTS/Resources/ashell.icns"
|
echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> "$GITHUB_ENV"
|
||||||
|
echo "PACKAGE_BASENAME=ashell-v${PACKAGE_VERSION}-${{ matrix.name }}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
VERSION_NUM="${VERSION#v}"
|
- name: Package macOS app, DMG, and portable archive
|
||||||
cat > "$CONTENTS/Info.plist" <<EOF
|
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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -110,9 +166,9 @@ jobs:
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>${VERSION_NUM}</string>
|
<string>${VERSION}</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>${GITHUB_RUN_NUMBER}</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
@@ -121,35 +177,284 @@ jobs:
|
|||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
printf 'APPL????' > "$CONTENTS/PkgInfo"
|
printf 'APPL????' > "$CONTENTS/PkgInfo"
|
||||||
|
plutil -lint "$CONTENTS/Info.plist"
|
||||||
|
|
||||||
# Ad-hoc code-sign the assembled bundle.
|
# Release builds remain ad-hoc signed until signing credentials are configured.
|
||||||
codesign --force --deep --sign - "$APP"
|
codesign --force --deep --sign - "$APP"
|
||||||
codesign --verify --verbose "$APP"
|
codesign --verify --deep --strict --verbose=2 "$APP"
|
||||||
|
|
||||||
# Package with ditto (Apple's tool) to preserve signature/metadata
|
ditto -c -k --sequesterRsrc --keepParent \
|
||||||
ditto -c -k --keepParent "$APP" "${STAGE}.zip"
|
"$APP" "dist/${PACKAGE_BASENAME}-portable.zip"
|
||||||
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
|
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
|
EXPECTED_ARCH="${{ matrix.binary_arch }}"
|
||||||
mkdir "$STAGE"
|
ACTUAL_ARCHS="$(lipo -archs "$CONTENTS/MacOS/ashell")"
|
||||||
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
|
if [ "$ACTUAL_ARCHS" != "$EXPECTED_ARCH" ]; then
|
||||||
7z a "${STAGE}.zip" "$STAGE" >/dev/null
|
echo "Expected $EXPECTED_ARCH binary, found $ACTUAL_ARCHS" >&2
|
||||||
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
|
exit 1
|
||||||
|
|
||||||
else
|
|
||||||
# Linux
|
|
||||||
mkdir "$STAGE"
|
|
||||||
cp "target/${{ matrix.target }}/release/${{ matrix.bin }}" "$STAGE/"
|
|
||||||
tar -czf "${STAGE}.tar.gz" "$STAGE"
|
|
||||||
echo "ASSET=${STAGE}.tar.gz" >> "$GITHUB_ENV"
|
|
||||||
fi
|
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
|
- name: Upload workflow artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.name }}
|
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:
|
publish:
|
||||||
name: Publish Release
|
name: Publish Release
|
||||||
@@ -161,10 +466,93 @@ jobs:
|
|||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Attach to GitHub Release
|
- name: Attach to GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: dist/**/*
|
files: dist/*
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
fail_on_unmatched_files: 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
|
.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
+468
-59
@@ -265,6 +265,18 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayref"
|
name = "arrayref"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -303,24 +315,40 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ashell"
|
name = "ashell"
|
||||||
version = "0.1.0"
|
version = "0.5.2-fix1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"argon2",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"chacha20poly1305",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"csv",
|
||||||
"directories",
|
"directories",
|
||||||
|
"encoding_rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-component",
|
"gpui-component",
|
||||||
"gpui-component-assets",
|
"gpui-component-assets",
|
||||||
"gpui_platform",
|
"gpui_platform",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"image",
|
"image",
|
||||||
|
"libc",
|
||||||
|
"mac-notification-sys",
|
||||||
"menu",
|
"menu",
|
||||||
"notify 6.1.1",
|
"notify 6.1.1",
|
||||||
|
"notify-rust",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-app-kit 0.3.2",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
"open",
|
"open",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"raw-window-handle",
|
||||||
|
"reqwest",
|
||||||
"rfd",
|
"rfd",
|
||||||
"russh",
|
"russh",
|
||||||
"russh-keys",
|
"russh-keys",
|
||||||
@@ -328,18 +356,25 @@ dependencies = [
|
|||||||
"rust-i18n 3.1.5",
|
"rust-i18n 3.1.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serialport",
|
||||||
|
"sha2",
|
||||||
"ssh-key",
|
"ssh-key",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"sysinfo 0.33.1",
|
"sysinfo 0.33.1",
|
||||||
"tar",
|
"tar",
|
||||||
|
"tauri-winrt-notification",
|
||||||
|
"tempfile",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-socks",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"windows 0.57.0",
|
||||||
|
"winreg 0.52.0",
|
||||||
"winres",
|
"winres",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
@@ -842,11 +877,19 @@ dependencies = [
|
|||||||
"no_std_io2",
|
"no_std_io2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block"
|
name = "block"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/Dicklesworthstone/rust-block?rev=b39ae859d1ee8e8cb5eef6a516471f1578d26b96#b39ae859d1ee8e8cb5eef6a516471f1578d26b96"
|
||||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
@@ -1091,6 +1134,19 @@ dependencies = [
|
|||||||
"cpufeatures",
|
"cpufeatures",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chacha20poly1305"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"chacha20",
|
||||||
|
"cipher",
|
||||||
|
"poly1305",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -1201,9 +1257,8 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "collections"
|
name = "collections"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui_util",
|
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
]
|
]
|
||||||
@@ -1546,6 +1601,27 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938"
|
||||||
|
dependencies = [
|
||||||
|
"csv-core",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csv-core"
|
||||||
|
version = "0.1.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctor"
|
name = "ctor"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -1698,7 +1774,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_refineable"
|
name = "derive_refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -2682,7 +2758,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui"
|
name = "gpui"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -2712,14 +2788,13 @@ dependencies = [
|
|||||||
"gpui_macros",
|
"gpui_macros",
|
||||||
"gpui_shared_string",
|
"gpui_shared_string",
|
||||||
"gpui_util",
|
"gpui_util",
|
||||||
"heapless",
|
|
||||||
"http_client",
|
"http_client",
|
||||||
"image",
|
"image",
|
||||||
"inventory",
|
"inventory",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"log",
|
"log",
|
||||||
"lyon",
|
"lyon",
|
||||||
"mach2",
|
"mach2 0.5.0",
|
||||||
"media",
|
"media",
|
||||||
"metal",
|
"metal",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
@@ -2832,7 +2907,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_linux"
|
name = "gpui_linux"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_unix",
|
"accesskit_unix",
|
||||||
@@ -2883,7 +2958,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macos"
|
name = "gpui_macos"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_macos",
|
"accesskit_macos",
|
||||||
@@ -2909,7 +2984,7 @@ dependencies = [
|
|||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mach2",
|
"mach2 0.5.0",
|
||||||
"media",
|
"media",
|
||||||
"metal",
|
"metal",
|
||||||
"objc",
|
"objc",
|
||||||
@@ -2928,7 +3003,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_macros"
|
name = "gpui_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -2939,7 +3014,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_platform"
|
name = "gpui_platform"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -2952,7 +3027,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_shared_string"
|
name = "gpui_shared_string"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2962,7 +3037,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_util"
|
name = "gpui_util"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"log",
|
"log",
|
||||||
@@ -2971,7 +3046,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_web"
|
name = "gpui_web"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
@@ -2995,7 +3070,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_wgpu"
|
name = "gpui_wgpu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
@@ -3024,7 +3099,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "gpui_windows"
|
name = "gpui_windows"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"accesskit_windows",
|
"accesskit_windows",
|
||||||
@@ -3273,7 +3348,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "http_client"
|
name = "http_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-compression",
|
"async-compression",
|
||||||
@@ -3336,6 +3411,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3344,13 +3420,16 @@ version = "0.1.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"ipnet",
|
||||||
"libc",
|
"libc",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3638,6 +3717,16 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "io-kit-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"mach2 0.4.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-surface"
|
name = "io-surface"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -3907,6 +3996,26 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libudev"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"libudev-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libudev-sys"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linebender_resource_handle"
|
name = "linebender_resource_handle"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -4054,6 +4163,29 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac-notification-sys"
|
||||||
|
version = "0.6.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"log",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"time",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mach2"
|
name = "mach2"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -4146,7 +4278,7 @@ checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "media"
|
name = "media"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bindgen",
|
"bindgen",
|
||||||
@@ -4194,7 +4326,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "menu"
|
name = "menu"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gpui",
|
"gpui",
|
||||||
]
|
]
|
||||||
@@ -4352,6 +4484,17 @@ dependencies = [
|
|||||||
"pin-utils",
|
"pin-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -4457,6 +4600,18 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notify-rust"
|
||||||
|
version = "4.11.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fa3b9f2364a09bd359aa0206702882e208437450866a374d5372d64aece4029"
|
||||||
|
dependencies = [
|
||||||
|
"futures-lite 2.6.1",
|
||||||
|
"log",
|
||||||
|
"mac-notification-sys",
|
||||||
|
"tauri-winrt-notification",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-types"
|
name = "notify-types"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -4684,8 +4839,8 @@ dependencies = [
|
|||||||
"block2 0.5.1",
|
"block2 0.5.1",
|
||||||
"libc",
|
"libc",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
"objc2-core-data",
|
"objc2-core-data 0.2.2",
|
||||||
"objc2-core-image",
|
"objc2-core-image 0.2.2",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
"objc2-quartz-core 0.2.2",
|
"objc2-quartz-core 0.2.2",
|
||||||
]
|
]
|
||||||
@@ -4698,6 +4853,26 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.12.1",
|
"bitflags 2.12.1",
|
||||||
"block2 0.6.2",
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-cloud-kit",
|
||||||
|
"objc2-core-data 0.3.2",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-core-image 0.3.2",
|
||||||
|
"objc2-core-text",
|
||||||
|
"objc2-core-video",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"objc2-quartz-core 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-cloud-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
]
|
]
|
||||||
@@ -4714,6 +4889,17 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-data"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-foundation"
|
name = "objc2-core-foundation"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -4725,6 +4911,19 @@ dependencies = [
|
|||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-graphics"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"dispatch2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-image"
|
name = "objc2-core-image"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4737,6 +4936,41 @@ dependencies = [
|
|||||||
"objc2-metal 0.2.2",
|
"objc2-metal 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-image"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-text"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-video"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-core-graphics",
|
||||||
|
"objc2-io-surface",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-encode"
|
name = "objc2-encode"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -4760,6 +4994,19 @@ name = "objc2-foundation"
|
|||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-io-surface"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.12.1",
|
"bitflags 2.12.1",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
@@ -5014,6 +5261,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "paste"
|
name = "paste"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@@ -5079,7 +5337,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "perf"
|
name = "perf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"collections",
|
"collections",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5452,28 +5710,6 @@ dependencies = [
|
|||||||
"toml_edit 0.25.12+spec-1.1.0",
|
"toml_edit 0.25.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro-error-attr2"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro-error2"
|
|
||||||
version = "2.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-error-attr2",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -5542,6 +5778,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -5861,7 +6106,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "refineable"
|
name = "refineable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"derive_refineable",
|
"derive_refineable",
|
||||||
]
|
]
|
||||||
@@ -5901,6 +6146,44 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"webpki-roots",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "resvg"
|
name = "resvg"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
@@ -6477,7 +6760,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "scheduler"
|
name = "scheduler"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-task",
|
"async-task",
|
||||||
"backtrace",
|
"backtrace",
|
||||||
@@ -6803,6 +7086,25 @@ dependencies = [
|
|||||||
"serial-core",
|
"serial-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serialport"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"cfg-if",
|
||||||
|
"core-foundation 0.10.0",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"io-kit-sys",
|
||||||
|
"libudev",
|
||||||
|
"mach2 0.4.3",
|
||||||
|
"nix 0.26.4",
|
||||||
|
"scopeguard",
|
||||||
|
"unescaper",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -7126,10 +7428,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "stacksafe-macro"
|
name = "stacksafe-macro"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro-error2",
|
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
@@ -7216,7 +7515,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "sum_tree"
|
name = "sum_tree"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heapless",
|
"heapless",
|
||||||
"log",
|
"log",
|
||||||
@@ -7466,6 +7765,17 @@ dependencies = [
|
|||||||
"xattr 1.6.1",
|
"xattr 1.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-winrt-notification"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f89f5fb70d6f62381f5d9b2ba9008196150b40b75f3068eb24faeddf1c686871"
|
||||||
|
dependencies = [
|
||||||
|
"quick-xml 0.31.0",
|
||||||
|
"windows 0.56.0",
|
||||||
|
"windows-version",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -7854,6 +8164,24 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.12.1",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-layer"
|
name = "tower-layer"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -8021,6 +8349,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unescaper"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
@@ -8182,7 +8519,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "util"
|
name = "util"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-fs",
|
"async-fs",
|
||||||
@@ -8198,7 +8535,7 @@ dependencies = [
|
|||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mach2",
|
"mach2 0.5.0",
|
||||||
"nix 0.29.0",
|
"nix 0.29.0",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -8221,7 +8558,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "util_macros"
|
name = "util_macros"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"perf",
|
"perf",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -8615,6 +8952,15 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -8824,6 +9170,16 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.56.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.56.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.57.0"
|
version = "0.57.0"
|
||||||
@@ -8900,6 +9256,18 @@ dependencies = [
|
|||||||
"windows-core 0.62.2",
|
"windows-core 0.62.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.56.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement 0.56.0",
|
||||||
|
"windows-interface 0.56.0",
|
||||||
|
"windows-result 0.1.2",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.57.0"
|
version = "0.57.0"
|
||||||
@@ -8973,6 +9341,17 @@ dependencies = [
|
|||||||
"windows-threading 0.2.1",
|
"windows-threading 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.56.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.57.0"
|
version = "0.57.0"
|
||||||
@@ -9006,6 +9385,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.56.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.57.0"
|
version = "0.57.0"
|
||||||
@@ -9277,6 +9667,15 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-version"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
@@ -9442,6 +9841,16 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winreg"
|
name = "winreg"
|
||||||
version = "0.55.0"
|
version = "0.55.0"
|
||||||
@@ -10098,7 +10507,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zlog"
|
name = "zlog"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -10127,7 +10536,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing"
|
name = "ztracing"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -10138,7 +10547,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "ztracing_macro"
|
name = "ztracing_macro"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/zed-industries/zed#dde98fa822c59028475850f242a4827ba770e24a"
|
source = "git+https://github.com/zed-industries/zed#969a67fcfbf799e68ab00854028228274be847e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-core"
|
name = "zune-core"
|
||||||
|
|||||||
+56
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ashell"
|
name = "ashell"
|
||||||
version = "0.1.0"
|
version = "0.5.2-fix1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["ashell contributors"]
|
authors = ["ashell contributors"]
|
||||||
description = "A GPUI Component based SSH and local terminal client"
|
description = "A GPUI Component based SSH and local terminal client"
|
||||||
@@ -9,10 +9,13 @@ rust-version = "1.85.0"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
tokio-socks = "0.5"
|
||||||
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
|
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "fcf32feacb367b75ec84dd40f041e4fd411d3cc1" }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
csv = "1"
|
||||||
directories = "5"
|
directories = "5"
|
||||||
|
encoding_rs = "0.8"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
gpui = { git = "https://github.com/zed-industries/zed" }
|
gpui = { git = "https://github.com/zed-industries/zed" }
|
||||||
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
|
gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "runtime_shaders", "wayland", "x11"] }
|
||||||
@@ -31,6 +34,7 @@ serde_json = "1"
|
|||||||
ssh-key = "0.6"
|
ssh-key = "0.6"
|
||||||
sysinfo = "0.33"
|
sysinfo = "0.33"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
|
tempfile = "3"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
@@ -44,6 +48,52 @@ notify = "6.1.1"
|
|||||||
open = "5.1"
|
open = "5.1"
|
||||||
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
|
time = { version = "0.3.45", features = ["formatting", "local-offset"] }
|
||||||
tracing-appender = "0.2.5"
|
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]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
@@ -53,3 +103,8 @@ strip = true
|
|||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
winres = "0.1.12"
|
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" }
|
||||||
|
|||||||
+109
-11
@@ -8,29 +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.
|
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.3 Massive Update!
|
## 🚀 v0.4 Major Upgrades
|
||||||
|
|
||||||
The brand-new v0.3 introduces comprehensive upgrades to both architecture and user experience:
|
v0.4 builds on the v0.3 foundation and focuses on more capable workspace operations and a smoother daily workflow:
|
||||||
- ✨ **Global Real-Time Font Settings** (Thanks to [@ancion](https://github.com/ancion) for [PR #6](https://github.com/rust-kotlin/ashell/pull/6)): Customize your global UI fonts and terminal rendering fonts independently. Adjust text size in real-time with automatic persistence.
|
- ✨ **Keybinding Management**: View and edit common shortcuts from the settings UI with conflict hints.
|
||||||
- ✨ **Concurrent SFTP Transfers**: A dedicated Transfers dialog now supports highly optimized, concurrent file uploads and downloads with precise progress display.
|
- ✨ **Settings Page Polish**: A more compact and clearer settings experience with improved layout and interaction flow.
|
||||||
- ✨ **Persistent Workspace State**: Your sidebar width adjustments, terminal themes, font settings, and file visibility preferences are completely restored upon your next launch!
|
- ✨ **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.
|
||||||
- ✨ **Intelligent SSH Disconnect/Reconnect**: Greatly enhanced SSH connection resilience. Provides a beautiful and localized overlay for one-click re-connection on unexpected disconnections.
|
- ✨ **Transfer History Improvements**: The transfer history panel now presents richer task details, making upload and download activity easier to review.
|
||||||
- ✨ **Hot-Swappable i18n System**: Deeply optimized internationalization. Switch instantly between English and Simplified Chinese without restarting the application.
|
- ✨ **SSH Passphrase Support**: Private keys can now store a passphrase, and SSH connections will use it automatically.
|
||||||
- ✨ **Smart Right-Click Actions**: Added intelligent right-click copy and paste for the terminal, featuring a dedicated settings toggle and OS-native keyboard shortcut hints.
|
- ✨ **Terminal Rendering Enhancements**: Terminal rendering now handles Block Elements and similar custom glyphs more completely.
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
You can download the latest pre-compiled releases for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest).
|
You can download the latest installers from the [GitHub Releases page](https://github.com/rust-kotlin/ashell/releases/latest):
|
||||||
|
|
||||||
|
- macOS: `.dmg` installers for x64 and arm64, plus portable `.zip` archives.
|
||||||
|
- Windows: `Setup.exe` installers for x64, x86, and arm64, plus portable `.zip` archives.
|
||||||
|
- Linux: a portable x64 `.tar.gz` archive.
|
||||||
|
|
||||||
## Mac Installation Guide
|
## Mac Installation Guide
|
||||||
|
|
||||||
After downloading, please unzip the file and move `ashell.app` to your **Applications** folder.
|
### Method 1: Homebrew (Recommended)
|
||||||
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:
|
|
||||||
|
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
|
```bash
|
||||||
sudo xattr -cr /Applications/ashell.app
|
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
|
## Features
|
||||||
|
|
||||||
The current version provides a fully-featured GPUI-native workspace:
|
The current version provides a fully-featured GPUI-native workspace:
|
||||||
@@ -43,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.
|
- **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.
|
- **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.
|
- **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
|
## Run
|
||||||
|
|
||||||
@@ -52,6 +90,33 @@ To run the application locally:
|
|||||||
cargo run --release
|
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
|
## Package macOS App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -61,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).
|
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
|
## License
|
||||||
|
|
||||||
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
|
This project is licensed under the [GPL-3.0-or-later License](LICENSE).
|
||||||
|
|||||||
@@ -8,29 +8,66 @@
|
|||||||
|
|
||||||
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
该项目旨在通过结合本地和远程环境,并内置丰富的核心功能,提供一个高性能且美观的 Shell 工作区。
|
||||||
|
|
||||||
## 🚀 v0.3 版本重磅更新!
|
## 🚀 v0.4 版本重要升级
|
||||||
|
|
||||||
全新发布的 v0.3 迎来了架构和体验的全面升级:
|
v0.4 在 v0.3 打下的基础上,重点带来了更完整的工作区操作能力和更顺手的日常体验:
|
||||||
- ✨ **全局实时字体设置与字号调节**(感谢 [@ancion](https://github.com/ancion) 提供的 [PR #6](https://github.com/rust-kotlin/ashell/pull/6)):支持自定义全局 UI 字体和终端渲染字体,支持实时字号调节并自动记忆。
|
- ✨ **键位管理功能**:支持在设置中可视化查看和修改常用快捷键,并提供冲突提示。
|
||||||
- ✨ **SFTP 多任务并发传输队列**:引入全新的独立传输对话框,支持多文件并发上传下载,提供精准的进度展示。
|
- ✨ **设置页优化**:设置页的布局和交互更加紧凑顺手,整体信息层级更清晰。
|
||||||
- ✨ **持久化工作区记忆**:您对侧边栏的尺寸拖拽、终端主题、字体、隐藏文件开关等一切设置都会在下次启动时原样恢复!
|
- ✨ **Tab 内多 Pane 与类 tmux 体验**:一个 tab 内现在可以管理多个 pane,支持分屏、聚焦和切换,提供更接近 tmux 的工作流。
|
||||||
- ✨ **智能断线重连与状态感知**:全面增强的 SSH 网络异常处理机制,断线后直接提供美观的覆盖层用于一键重连或关闭。
|
- ✨ **传输历史增强**:传输历史面板展示更完整的任务信息,便于回溯上传和下载过程。
|
||||||
- ✨ **多语言热切换增强**:深度优化的 i18n 系统,包含中文与英文在内的所有界面文案现在均支持热切换。
|
- ✨ **SSH passphrase 支持**:现在可以为私钥填写 passphrase,并在连接时自动使用保存的口令。
|
||||||
- ✨ **智能右键快捷操作**:新增终端右键智能复制与粘贴功能,提供专属设置项自由控制开关,并支持根据不同操作系统自适应显示原生快捷键提示。
|
- ✨ **终端显示优化**:终端渲染进一步增强,支持 Block Elements 等自定义图形字符的更完整显示。
|
||||||
|
|
||||||
## 下载
|
## 下载
|
||||||
|
|
||||||
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载 macOS、Windows 和 Linux 版本的最新预编译程序。
|
您可以从 [GitHub Releases 页面](https://github.com/rust-kotlin/ashell/releases/latest) 下载最新安装包:
|
||||||
|
|
||||||
|
- macOS:x64 和 arm64 的 `.dmg`,同时提供便携 `.zip`。
|
||||||
|
- Windows:x64、x86 和 arm64 的 `Setup.exe`,同时提供便携 `.zip`。
|
||||||
|
- Linux:x64 便携 `.tar.gz`。
|
||||||
|
|
||||||
## Mac 安装指南
|
## Mac 安装指南
|
||||||
|
|
||||||
下载并解压后,请先将 `ashell.app` 拖入或移动到 **应用程序 (Applications)** 目录。
|
### 方法 1: Homebrew 安装 (推荐)
|
||||||
由于应用采用本地签名,初次启动时如果系统提示“App 已损坏,无法打开”,请打开终端(Terminal)并执行以下命令:
|
|
||||||
|
如果您使用 [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
|
```bash
|
||||||
sudo xattr -cr /Applications/ashell.app
|
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 原生工作区:
|
当前版本提供了一个功能完备的 GPUI 原生工作区:
|
||||||
@@ -43,6 +80,7 @@ sudo xattr -cr /Applications/ashell.app
|
|||||||
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
|
- **系统遥测**:在左侧边栏实时可视化显示 CPU、内存、Swap、网络和磁盘的使用指标。
|
||||||
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
|
- **主题系统**:支持直接从顶部工具栏切换多种 GPUI Component 颜色主题。
|
||||||
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
|
- **内置字体**:开箱即用,内置 Maple Mono NF CN 字体,提供卓越的中日韩(CJK)字符与 Nerd Font 图标支持。
|
||||||
|
- **v0.3 核心增强**:全局字体与字号可调,支持 SFTP 并发传输、布局记忆、断线感知、多语言热切换,以及终端右键复制粘贴等增强能力。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
@@ -52,6 +90,37 @@ sudo xattr -cr /Applications/ashell.app
|
|||||||
cargo run --release
|
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 应用
|
## 打包 macOS 应用
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -61,6 +130,39 @@ open target/release/ashell.app
|
|||||||
|
|
||||||
该打包脚本会创建一个标准的 `.app` 应用程序包。它没有附加 entitlements 文件,并且在签名后会验证是否不存在 `com.apple.security.app-sandbox`(这意味着它在非沙盒模式下运行)。
|
该打包脚本会创建一个标准的 `.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) 开源。
|
本项目采用 [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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+276
-19
@@ -1,7 +1,21 @@
|
|||||||
remote_files: "REMOTE FILES"
|
|
||||||
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
|
open_ssh_tab_sftp: "Open an SSH tab to browse SFTP files."
|
||||||
sftp_connecting: "sftp connecting..."
|
sftp_connecting: "sftp connecting..."
|
||||||
refresh: "Refresh"
|
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_file: "Upload File"
|
||||||
upload_folder: "Upload Folder"
|
upload_folder: "Upload Folder"
|
||||||
hidden: "Hidden"
|
hidden: "Hidden"
|
||||||
@@ -11,9 +25,35 @@ download_count: "Download (%{count})"
|
|||||||
name: "NAME"
|
name: "NAME"
|
||||||
size: "SIZE"
|
size: "SIZE"
|
||||||
modified: "MODIFIED"
|
modified: "MODIFIED"
|
||||||
new_ssh_connection: "New SSH Connection"
|
new_connection: "New Connection"
|
||||||
|
edit_connection: "Edit Connection"
|
||||||
create_or_edit_ssh_session: "Create or edit an SSH session"
|
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"
|
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_success: "Created folder: %{name}"
|
||||||
create_folder_failed: "Failed to create folder: %{err}"
|
create_folder_failed: "Failed to create folder: %{err}"
|
||||||
delete_selected: "Delete"
|
delete_selected: "Delete"
|
||||||
@@ -23,21 +63,23 @@ system_path_warning: "WARNING: You are attempting to delete critical system path
|
|||||||
delete_success: "Successfully deleted %{count} items"
|
delete_success: "Successfully deleted %{count} items"
|
||||||
delete_failed: "Failed to delete: %{err}"
|
delete_failed: "Failed to delete: %{err}"
|
||||||
deleting_paths: "Deleting %{count} items..."
|
deleting_paths: "Deleting %{count} items..."
|
||||||
|
confirm: "Confirm"
|
||||||
local_terminal: "Local Terminal"
|
local_terminal: "Local Terminal"
|
||||||
open_local_shell_tab: "Open a new local shell tab"
|
open_local_shell_tab: "Open a new local shell tab"
|
||||||
connect: "Connect"
|
connect: "Connect"
|
||||||
edit: "Edit"
|
edit: "Edit"
|
||||||
|
done: "Done"
|
||||||
delete: "Delete"
|
delete: "Delete"
|
||||||
host: "Host"
|
host: "Host"
|
||||||
port: "Port"
|
port: "Port"
|
||||||
user: "User"
|
user: "User"
|
||||||
password: "Password"
|
password: "Password"
|
||||||
key: "Key"
|
key: "Key"
|
||||||
private_key_path: "Private Key Path"
|
ssh_config: "Config(~/.ssh/config)"
|
||||||
private_key_data: "Private Key Data"
|
ssh_config_empty: "No entries found in ~/.ssh/config"
|
||||||
save_and_connect: "Save & Connect"
|
|
||||||
save: "Save"
|
save: "Save"
|
||||||
cancel: "Cancel"
|
cancel: "Cancel"
|
||||||
|
more: "More"
|
||||||
host_and_user_required: "Host and user are required"
|
host_and_user_required: "Host and user are required"
|
||||||
language: "Language"
|
language: "Language"
|
||||||
follow_system: "Follow System"
|
follow_system: "Follow System"
|
||||||
@@ -47,36 +89,105 @@ use_light_mode: "Use Light Mode"
|
|||||||
use_dark_mode: "Use Dark Mode"
|
use_dark_mode: "Use Dark Mode"
|
||||||
english: "English"
|
english: "English"
|
||||||
chinese: "简体中文"
|
chinese: "简体中文"
|
||||||
idle_no_session: "idle / No session"
|
|
||||||
system: "SYSTEM"
|
|
||||||
cpu: "CPU"
|
cpu: "CPU"
|
||||||
mem: "MEM"
|
mem: "MEM"
|
||||||
swap: "SWAP"
|
swap: "SWAP"
|
||||||
net: "NET"
|
net: "NET"
|
||||||
disk: "DISK"
|
disk: "DISK"
|
||||||
global_load: "global load"
|
|
||||||
live: "Live"
|
live: "Live"
|
||||||
idle: "idle"
|
system_info: "SYSTEM"
|
||||||
saved: "SAVED"
|
memory: "Memory"
|
||||||
add_ssh: "+ ssh"
|
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_local_or_ssh: "Open a local terminal or pick an SSH session."
|
||||||
open_session: "Open Session"
|
open_session: "Open Session"
|
||||||
settings: "Settings"
|
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"
|
ui_font_size: "UI Font Size"
|
||||||
terminal_font_size: "Terminal Font Size"
|
terminal_font_size: "Terminal Font Size"
|
||||||
system_default: "System Default"
|
system_default: "System Default"
|
||||||
ui_font_family: "UI Font"
|
ui_font_family: "UI Font"
|
||||||
terminal_font_family: "Terminal Font"
|
terminal_font_family: "Terminal Font"
|
||||||
theme_management_hint: "Theme mode and light/dark theme families are managed from the top-bar theme button."
|
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"
|
monitoring_position: "Monitoring Position"
|
||||||
position_bottom: "Bottom"
|
position_bottom: "Bottom"
|
||||||
position_sidebar: "Sidebar"
|
position_sidebar: "Sidebar"
|
||||||
position_hidden: "Hidden"
|
position_hidden: "Hidden"
|
||||||
local_host: "local host"
|
|
||||||
local_terminal_opened: "local terminal opened"
|
|
||||||
connecting: "Connecting"
|
connecting: "Connecting"
|
||||||
starting_connection: "starting connection..."
|
starting_connection: "starting connection..."
|
||||||
connection_failed: "Connection Failed"
|
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}"
|
preview_failed: "Preview failed: %{err}"
|
||||||
downloading_file: "Downloading %{base}..."
|
downloading_file: "Downloading %{base}..."
|
||||||
@@ -95,23 +206,169 @@ n_folders: "%{folders} folders"
|
|||||||
n_files_and_folders: "%{files} files and %{folders} folders"
|
n_files_and_folders: "%{files} files and %{folders} folders"
|
||||||
reset: "Reset"
|
reset: "Reset"
|
||||||
reset_layout: "Reset Default Layout"
|
reset_layout: "Reset Default Layout"
|
||||||
reset_layout_success: "Layout reset. Window size takes effect on restart."
|
reset_layout_hint: "Reset panel layout proportion. Window size will take effect on next startup."
|
||||||
|
|
||||||
transfers: "Transfers"
|
transfers: "Transfers"
|
||||||
no_transfers_yet: "No transfers yet"
|
no_transfers_yet: "No transfers yet"
|
||||||
downloading: "Downloading"
|
downloading: "Downloading"
|
||||||
paused: "Paused"
|
paused: "Paused"
|
||||||
completed: "Completed"
|
completed: "Completed"
|
||||||
|
terminal_attention_required: "Terminal requires attention"
|
||||||
failed: "Failed"
|
failed: "Failed"
|
||||||
cancelled: "Cancelled"
|
interrupted: "Interrupted"
|
||||||
session: "Session"
|
session: "Session"
|
||||||
|
zombie: "Zombie"
|
||||||
|
zombie_reason: "This transfer was interrupted because the application exited. The transfer task no longer exists."
|
||||||
|
|
||||||
software_builtin: "Built-in"
|
software_builtin: "Built-in"
|
||||||
retry: "Retry"
|
retry: "Retry"
|
||||||
right_click_copy_paste: "Right-click Copy/Paste"
|
right_click_copy_paste: "Right-click Copy/Paste"
|
||||||
copy_paste_hint: "Tip: Regardless of this switch, you can always use %{key} + C/V to 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: "Edit File"
|
||||||
edit_file_tooltip: "Download to temporary directory, open in default editor, and auto upload on save."
|
edit_file_tooltip: "Edit directly in ashell and save changes to the remote server."
|
||||||
auto_saved_and_uploaded: "Auto-saved & uploaded: %{base}"
|
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 ↓"
|
scroll: "Scroll ↓"
|
||||||
clone: "Clone"
|
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"
|
||||||
|
|||||||
+278
-23
@@ -1,7 +1,21 @@
|
|||||||
remote_files: "远程文件"
|
open_ssh_tab_sftp: "打开 SSH 标签页以浏览 SFTP 文件。"
|
||||||
open_ssh_tab_sftp: "打开一个 SSH 会话面板以浏览 SFTP 文件。"
|
|
||||||
sftp_connecting: "正在连接 SFTP..."
|
sftp_connecting: "正在连接 SFTP..."
|
||||||
refresh: "刷新"
|
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_file: "上传文件"
|
||||||
upload_folder: "上传文件夹"
|
upload_folder: "上传文件夹"
|
||||||
hidden: "显示隐藏文件"
|
hidden: "显示隐藏文件"
|
||||||
@@ -11,9 +25,35 @@ download_count: "下载 (%{count})"
|
|||||||
name: "名称"
|
name: "名称"
|
||||||
size: "大小"
|
size: "大小"
|
||||||
modified: "修改时间"
|
modified: "修改时间"
|
||||||
new_ssh_connection: "新建 SSH 连接"
|
new_connection: "新建连接"
|
||||||
|
edit_connection: "编辑连接"
|
||||||
create_or_edit_ssh_session: "创建或编辑一个 SSH 会话"
|
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: "新建文件夹"
|
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_success: "成功创建文件夹: %{name}"
|
||||||
create_folder_failed: "创建文件夹失败: %{err}"
|
create_folder_failed: "创建文件夹失败: %{err}"
|
||||||
delete_selected: "删除"
|
delete_selected: "删除"
|
||||||
@@ -23,23 +63,23 @@ system_path_warning: "警告:您即将删除系统关键路径!这可能会
|
|||||||
delete_success: "成功删除 %{count} 个项"
|
delete_success: "成功删除 %{count} 个项"
|
||||||
delete_failed: "删除失败: %{err}"
|
delete_failed: "删除失败: %{err}"
|
||||||
deleting_paths: "正在删除 %{count} 个项..."
|
deleting_paths: "正在删除 %{count} 个项..."
|
||||||
cancel: "取消"
|
|
||||||
confirm: "确认"
|
confirm: "确认"
|
||||||
local_terminal: "本地终端"
|
local_terminal: "本地终端"
|
||||||
open_local_shell_tab: "打开一个新的本地终端页面"
|
open_local_shell_tab: "打开一个新的本地终端标签页"
|
||||||
connect: "连接"
|
connect: "连接"
|
||||||
edit: "编辑"
|
edit: "编辑"
|
||||||
|
done: "完成"
|
||||||
delete: "删除"
|
delete: "删除"
|
||||||
host: "主机 (Host)"
|
host: "主机 (Host)"
|
||||||
port: "端口 (Port)"
|
port: "端口 (Port)"
|
||||||
user: "用户 (User)"
|
user: "用户 (User)"
|
||||||
password: "密码 (Password)"
|
password: "密码 (Password)"
|
||||||
key: "密钥 (Key)"
|
key: "密钥 (Key)"
|
||||||
private_key_path: "私钥路径"
|
ssh_config: "配置(~/.ssh/config)"
|
||||||
private_key_data: "私钥内容"
|
ssh_config_empty: "~/.ssh/config 中未找到条目"
|
||||||
save_and_connect: "保存并连接"
|
|
||||||
save: "保存"
|
save: "保存"
|
||||||
cancel: "取消"
|
cancel: "取消"
|
||||||
|
more: "更多"
|
||||||
host_and_user_required: "主机名和用户名不能为空"
|
host_and_user_required: "主机名和用户名不能为空"
|
||||||
language: "显示语言"
|
language: "显示语言"
|
||||||
follow_system: "跟随系统"
|
follow_system: "跟随系统"
|
||||||
@@ -49,36 +89,105 @@ use_light_mode: "使用浅色模式"
|
|||||||
use_dark_mode: "使用深色模式"
|
use_dark_mode: "使用深色模式"
|
||||||
english: "English"
|
english: "English"
|
||||||
chinese: "简体中文"
|
chinese: "简体中文"
|
||||||
idle_no_session: "空闲 / 未连接会话"
|
|
||||||
system: "系统"
|
|
||||||
cpu: "CPU"
|
cpu: "CPU"
|
||||||
mem: "内存"
|
mem: "内存"
|
||||||
swap: "虚拟内存"
|
swap: "虚拟内存"
|
||||||
net: "网络"
|
net: "网络"
|
||||||
disk: "磁盘"
|
disk: "磁盘"
|
||||||
global_load: "全局负载"
|
|
||||||
live: "实时"
|
live: "实时"
|
||||||
idle: "空闲"
|
system_info: "系统信息"
|
||||||
saved: "已保存会话"
|
memory: "内存"
|
||||||
add_ssh: "+ SSH"
|
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_local_or_ssh: "打开本地终端或选择一个 SSH 会话。"
|
||||||
open_session: "打开会话"
|
open_session: "打开会话"
|
||||||
settings: "设置"
|
settings: "设置"
|
||||||
|
proxy: "代理"
|
||||||
|
proxy_none: "跟随全局"
|
||||||
|
proxy_host: "代理服务器地址"
|
||||||
|
proxy_port: "端口"
|
||||||
|
proxy_user: "用户名 (可选)"
|
||||||
|
proxy_password: "密码 (可选)"
|
||||||
ui_font_size: "界面字体大小"
|
ui_font_size: "界面字体大小"
|
||||||
terminal_font_size: "终端字体大小"
|
terminal_font_size: "终端字体大小"
|
||||||
system_default: "系统默认"
|
system_default: "系统默认"
|
||||||
ui_font_family: "界面字体"
|
ui_font_family: "界面字体"
|
||||||
terminal_font_family: "终端字体"
|
terminal_font_family: "终端字体"
|
||||||
theme_management_hint: "深色/浅色模式以及主题配色由顶部栏的样式按钮统一管理。"
|
cursor_style: "光标样式"
|
||||||
monitoring_position: "系统监控显示位置"
|
cursor_style_default: "默认"
|
||||||
|
cursor_style_blink: "闪烁"
|
||||||
|
cursor_style_beam: "细线"
|
||||||
|
cursor_style_beam_blink: "细线闪烁"
|
||||||
|
monitoring_position: "监控显示位置"
|
||||||
position_bottom: "底部"
|
position_bottom: "底部"
|
||||||
position_sidebar: "侧边栏"
|
position_sidebar: "侧边栏"
|
||||||
position_hidden: "不显示"
|
position_hidden: "不显示"
|
||||||
local_host: "本地主机"
|
|
||||||
local_terminal_opened: "已打开本地终端"
|
|
||||||
connecting: "正在连接"
|
connecting: "正在连接"
|
||||||
starting_connection: "开始连接..."
|
starting_connection: "开始连接..."
|
||||||
connection_failed: "连接失败"
|
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}"
|
preview_failed: "预览失败: %{err}"
|
||||||
downloading_file: "正在下载 %{base}..."
|
downloading_file: "正在下载 %{base}..."
|
||||||
@@ -97,23 +206,169 @@ n_folders: "%{folders} 个文件夹"
|
|||||||
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
|
n_files_and_folders: "%{files} 个文件及 %{folders} 个文件夹"
|
||||||
reset: "重置"
|
reset: "重置"
|
||||||
reset_layout: "恢复默认布局"
|
reset_layout: "恢复默认布局"
|
||||||
reset_layout_success: "布局占比已恢复默认。窗口大小将在下次启动时生效。"
|
reset_layout_hint: "重置面板布局占比,窗口大小将在下次启动时生效"
|
||||||
|
|
||||||
transfers: "传输记录"
|
transfers: "传输记录"
|
||||||
no_transfers_yet: "暂无传输记录"
|
no_transfers_yet: "暂无传输记录"
|
||||||
downloading: "正在下载"
|
downloading: "正在下载"
|
||||||
paused: "已暂停"
|
paused: "已暂停"
|
||||||
completed: "已完成"
|
completed: "已完成"
|
||||||
|
terminal_attention_required: "终端中有需要关注的新状态"
|
||||||
failed: "失败"
|
failed: "失败"
|
||||||
cancelled: "已取消"
|
interrupted: "已中断"
|
||||||
session: "会话"
|
session: "会话"
|
||||||
|
zombie: "僵尸任务"
|
||||||
|
zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。"
|
||||||
|
|
||||||
software_builtin: "软件内置"
|
software_builtin: "软件内置"
|
||||||
retry: "重试"
|
retry: "重试"
|
||||||
right_click_copy_paste: "右键复制/粘贴"
|
right_click_copy_paste: "右键复制/粘贴"
|
||||||
copy_paste_hint: "提示:不管是否开启此开关,都可以使用 %{key} + C/V 快捷键进行复制和粘贴。"
|
keyword_highlight: "终端关键词高亮"
|
||||||
|
remember_tabs: "记忆标签页"
|
||||||
|
lock_layout: "锁定窗口布局"
|
||||||
|
lock_layout_hint: "锁定侧边栏和 SFTP 面板的位置。"
|
||||||
|
copy_paste_hint: "选中后左键点击也可复制。"
|
||||||
|
settings_copy: "复制选区"
|
||||||
|
settings_paste: "粘贴剪贴板"
|
||||||
edit_file: "编辑文件"
|
edit_file: "编辑文件"
|
||||||
edit_file_tooltip: "下载到临时目录,使用默认编辑器打开,并在保存后自动上传。"
|
edit_file_tooltip: "直接在 ashell 内编辑,并将修改保存到远程服务器。"
|
||||||
auto_saved_and_uploaded: "已自动保存并上传: %{base}"
|
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: "滑动查看 ↓"
|
scroll: "滑动查看 ↓"
|
||||||
clone: "克隆"
|
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()
|
||||||
|
}
|
||||||
@@ -9,6 +9,13 @@ CONTENTS_DIR="$APP_DIR/Contents"
|
|||||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||||
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
|
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"
|
cd "$ROOT_DIR"
|
||||||
cargo build --release
|
cargo build --release
|
||||||
@@ -40,9 +47,9 @@ cat > "$CONTENTS_DIR/Info.plist" <<EOF
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.1.0</string>
|
<string>$APP_VERSION</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>$APP_BUILD_VERSION</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
pub(crate) const DEFAULT_COLS: u16 = 100;
|
pub(crate) const DEFAULT_COLS: u16 = 100;
|
||||||
pub(crate) const DEFAULT_ROWS: u16 = 30;
|
pub(crate) const DEFAULT_ROWS: u16 = 30;
|
||||||
pub(crate) const SIDEBAR_WIDTH: f32 = 306.0;
|
pub(crate) const SIDEBAR_WIDTH: f32 = 310.0;
|
||||||
pub(crate) const TAB_BAR_HEIGHT: f32 = 52.0;
|
pub(crate) const TERMINAL_SCROLLBAR_GUTTER: f32 = 16.0;
|
||||||
pub(crate) const TERMINAL_PADDING_X: f32 = 32.0;
|
|
||||||
pub(crate) const TERMINAL_PADDING_Y: f32 = 32.0;
|
|
||||||
pub(crate) const TERMINAL_KEY_CONTEXT: &str = "AshellTerminal";
|
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,
|
||||||
|
}
|
||||||
+3867
-714
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1733
-175
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
-82
@@ -1,8 +1,81 @@
|
|||||||
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
|
use gpui::{App, AppContext as _, Bounds, WindowOptions, point, px, size};
|
||||||
use gpui_component::Root;
|
use gpui_component::Root;
|
||||||
|
|
||||||
use crate::session::config::ConfigStore;
|
|
||||||
use crate::Ashell;
|
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 {
|
struct LocalMinutelyRoller {
|
||||||
dir: std::path::PathBuf,
|
dir: std::path::PathBuf,
|
||||||
@@ -13,9 +86,14 @@ struct LocalMinutelyRoller {
|
|||||||
|
|
||||||
impl LocalMinutelyRoller {
|
impl LocalMinutelyRoller {
|
||||||
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
|
fn new(dir: std::path::PathBuf, prefix: String) -> Self {
|
||||||
Self { dir, prefix, current_minute: 60, file: None }
|
Self {
|
||||||
|
dir,
|
||||||
|
prefix,
|
||||||
|
current_minute: 60,
|
||||||
|
file: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
|
fn rollover(&mut self, now: chrono::DateTime<chrono::Local>) -> std::io::Result<()> {
|
||||||
use chrono::Timelike;
|
use chrono::Timelike;
|
||||||
let minute = now.minute();
|
let minute = now.minute();
|
||||||
@@ -28,14 +106,18 @@ impl LocalMinutelyRoller {
|
|||||||
.open(&path)?;
|
.open(&path)?;
|
||||||
self.file = Some(file);
|
self.file = Some(file);
|
||||||
self.current_minute = minute;
|
self.current_minute = minute;
|
||||||
|
|
||||||
// Cleanup old files keeping last 6
|
// Cleanup old files keeping last 6
|
||||||
if let Ok(entries) = std::fs::read_dir(&self.dir) {
|
if let Ok(entries) = std::fs::read_dir(&self.dir) {
|
||||||
let mut files: Vec<_> = entries
|
let mut files: Vec<_> = entries
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
|
.filter(|e| e.file_name().to_string_lossy().starts_with(&self.prefix))
|
||||||
.collect();
|
.collect();
|
||||||
files.sort_by_key(|e| e.metadata().and_then(|m| m.modified()).unwrap_or(std::time::SystemTime::UNIX_EPOCH));
|
files.sort_by_key(|e| {
|
||||||
|
e.metadata()
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
|
||||||
|
});
|
||||||
if files.len() > 6 {
|
if files.len() > 6 {
|
||||||
for file in files.iter().take(files.len() - 6) {
|
for file in files.iter().take(files.len() - 6) {
|
||||||
let _ = std::fs::remove_file(file.path());
|
let _ = std::fs::remove_file(file.path());
|
||||||
@@ -57,7 +139,7 @@ impl std::io::Write for LocalMinutelyRoller {
|
|||||||
Ok(buf.len())
|
Ok(buf.len())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn flush(&mut self) -> std::io::Result<()> {
|
fn flush(&mut self) -> std::io::Result<()> {
|
||||||
if let Some(f) = &mut self.file {
|
if let Some(f) = &mut self.file {
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -73,11 +155,11 @@ pub(crate) fn init_logging() {
|
|||||||
let log_dir = directories::BaseDirs::new()
|
let log_dir = directories::BaseDirs::new()
|
||||||
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
|
.map(|dirs| dirs.home_dir().join(".config").join("ashell").join("log"))
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||||
|
|
||||||
std::fs::create_dir_all(&log_dir).ok();
|
std::fs::create_dir_all(&log_dir).ok();
|
||||||
|
|
||||||
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
|
let roller = LocalMinutelyRoller::new(log_dir.clone(), "ashell".to_string());
|
||||||
|
|
||||||
let (non_blocking, _guard) = tracing_appender::non_blocking(roller);
|
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
|
// Leak the guard so it lives for the entire duration of the app since GPUI's run might not return
|
||||||
std::mem::forget(_guard);
|
std::mem::forget(_guard);
|
||||||
@@ -86,11 +168,15 @@ pub(crate) fn init_logging() {
|
|||||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||||
|
|
||||||
let stdout_layer = if cfg!(debug_assertions) {
|
let stdout_layer = if cfg!(debug_assertions) {
|
||||||
Some(tracing_subscriber::fmt::layer().with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()).with_target(true))
|
Some(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||||
|
.with_target(true),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let file_layer = tracing_subscriber::fmt::layer()
|
let file_layer = tracing_subscriber::fmt::layer()
|
||||||
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
.with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339())
|
||||||
.with_writer(non_blocking)
|
.with_writer(non_blocking)
|
||||||
@@ -107,7 +193,10 @@ pub(crate) fn init_logging() {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub(crate) fn sync_macos_launch_environment() {
|
pub(crate) fn sync_macos_launch_environment() {
|
||||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
|
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 {
|
let Ok(output) = std::process::Command::new(&shell)
|
||||||
|
.args(["-l", "-c", "env -0"])
|
||||||
|
.output()
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
@@ -141,6 +230,12 @@ pub(crate) fn sync_macos_launch_environment() {
|
|||||||
| "HOMEBREW_PREFIX"
|
| "HOMEBREW_PREFIX"
|
||||||
| "HOMEBREW_CELLAR"
|
| "HOMEBREW_CELLAR"
|
||||||
| "HOMEBREW_REPOSITORY"
|
| "HOMEBREW_REPOSITORY"
|
||||||
|
| "HTTP_PROXY"
|
||||||
|
| "HTTPS_PROXY"
|
||||||
|
| "ALL_PROXY"
|
||||||
|
| "http_proxy"
|
||||||
|
| "https_proxy"
|
||||||
|
| "all_proxy"
|
||||||
) || key.starts_with("LC_");
|
) || key.starts_with("LC_");
|
||||||
|
|
||||||
if should_import {
|
if should_import {
|
||||||
@@ -151,48 +246,80 @@ pub(crate) fn sync_macos_launch_environment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_proxy_from_env() -> Option<(String, String, Option<u16>, String, String)> {
|
||||||
|
let vars = [
|
||||||
|
"ALL_PROXY",
|
||||||
|
"all_proxy",
|
||||||
|
"HTTPS_PROXY",
|
||||||
|
"https_proxy",
|
||||||
|
"HTTP_PROXY",
|
||||||
|
"http_proxy",
|
||||||
|
];
|
||||||
|
for var in vars {
|
||||||
|
if let Ok(val) = std::env::var(var) {
|
||||||
|
if val.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(url) = reqwest::Url::parse(&val) {
|
||||||
|
let scheme = url.scheme();
|
||||||
|
let proxy_type = match scheme {
|
||||||
|
"socks5" | "socks5h" => "socks5".to_string(),
|
||||||
|
"http" | "https" => "http".to_string(),
|
||||||
|
_ => "socks5".to_string(),
|
||||||
|
};
|
||||||
|
let host = url.host_str().unwrap_or("").to_string();
|
||||||
|
let port = url.port();
|
||||||
|
let user = url.username().to_string();
|
||||||
|
let password = url.password().unwrap_or("").to_string();
|
||||||
|
return Some((proxy_type, host, port, user, password));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
pub(crate) fn sync_macos_launch_environment() {}
|
pub(crate) fn sync_macos_launch_environment() {}
|
||||||
|
|
||||||
pub(crate) fn open_main_window(cx: &mut App) {
|
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();
|
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"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
if let Ok(img) = image::load_from_memory(include_bytes!("../../assets/icons/ashell.png")) {
|
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()));
|
window_options.icon = Some(std::sync::Arc::new(img.into_rgba8()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
|
||||||
if let Some(bounds) = config.window_bounds() {
|
if let Some(bounds) = config.window_bounds() {
|
||||||
window_options.window_bounds = Some(match bounds {
|
window_options.window_bounds = Some(restore_window_bounds(bounds, cx));
|
||||||
crate::session::config::SavedWindowBounds::Fullscreen {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
} => gpui::WindowBounds::Fullscreen(Bounds::new(
|
|
||||||
point(px(*x), px(*y)),
|
|
||||||
size(px(*width), px(*height)),
|
|
||||||
)),
|
|
||||||
crate::session::config::SavedWindowBounds::Maximized {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
} => gpui::WindowBounds::Maximized(Bounds::new(
|
|
||||||
point(px(*x), px(*y)),
|
|
||||||
size(px(*width), px(*height)),
|
|
||||||
)),
|
|
||||||
crate::session::config::SavedWindowBounds::Windowed {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
} => gpui::WindowBounds::Windowed(Bounds::new(
|
|
||||||
point(px(*x), px(*y)),
|
|
||||||
size(px(*width), px(*height)),
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
} else if let Some(display) = cx.displays().first().cloned() {
|
} else if let Some(display) = cx.displays().first().cloned() {
|
||||||
let display_bounds = display.bounds();
|
let display_bounds = display.bounds();
|
||||||
let width = display_bounds.size.width * 0.8;
|
let width = display_bounds.size.width * 0.8;
|
||||||
@@ -216,49 +343,24 @@ pub(crate) fn open_main_window(cx: &mut App) {
|
|||||||
window.set_window_title("ashell");
|
window.set_window_title("ashell");
|
||||||
gpui_component::Theme::sync_system_appearance(Some(window), cx);
|
gpui_component::Theme::sync_system_appearance(Some(window), cx);
|
||||||
let view = cx.new(|cx| Ashell::new(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");
|
tracing::info!("[ui] main application window opened");
|
||||||
|
let focus_handle = view.read(cx).focus_handle.clone();
|
||||||
let workspace_panels_clone = view.read(cx).workspace_panels.clone();
|
window.focus(&focus_handle, cx);
|
||||||
let body_panels_clone = view.read(cx).body_panels.clone();
|
|
||||||
|
let view_clone = view.clone();
|
||||||
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
|
window.on_window_should_close(cx, move |window: &mut gpui::Window, cx: &mut gpui::App| {
|
||||||
tracing::info!("[ui] main application window closed, saving layout state...");
|
let handle = window.window_handle();
|
||||||
let mut config = ConfigStore::load().unwrap_or_else(|_| ConfigStore::in_memory());
|
if !cx.windows().contains(&handle) {
|
||||||
let current_bounds = window.window_bounds();
|
tracing::warn!(
|
||||||
let saved_bounds = match current_bounds {
|
"[ui] window not found in app during close, skipping save layout state."
|
||||||
gpui::WindowBounds::Fullscreen(b) => crate::session::config::SavedWindowBounds::Fullscreen {
|
);
|
||||||
x: b.origin.x.into(),
|
return true;
|
||||||
y: b.origin.y.into(),
|
}
|
||||||
width: b.size.width.into(),
|
view_clone.update(cx, |this, cx| {
|
||||||
height: b.size.height.into(),
|
this.save_layout_state(window, cx);
|
||||||
},
|
});
|
||||||
gpui::WindowBounds::Maximized(b) => crate::session::config::SavedWindowBounds::Maximized {
|
|
||||||
x: b.origin.x.into(),
|
|
||||||
y: b.origin.y.into(),
|
|
||||||
width: b.size.width.into(),
|
|
||||||
height: b.size.height.into(),
|
|
||||||
},
|
|
||||||
gpui::WindowBounds::Windowed(b) => crate::session::config::SavedWindowBounds::Windowed {
|
|
||||||
x: b.origin.x.into(),
|
|
||||||
y: b.origin.y.into(),
|
|
||||||
width: b.size.width.into(),
|
|
||||||
height: b.size.height.into(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let workspace_sizes: Vec<f32> = workspace_panels_clone
|
|
||||||
.read(cx)
|
|
||||||
.sizes()
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.into())
|
|
||||||
.collect();
|
|
||||||
let body_sizes: Vec<f32> = body_panels_clone
|
|
||||||
.read(cx)
|
|
||||||
.sizes()
|
|
||||||
.iter()
|
|
||||||
.map(|s| s.into())
|
|
||||||
.collect();
|
|
||||||
config.set_layout_state(Some(saved_bounds), Some(workspace_sizes), Some(body_sizes));
|
|
||||||
let _ = config.save();
|
|
||||||
true
|
true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -266,4 +368,3 @@ pub(crate) fn open_main_window(cx: &mut App) {
|
|||||||
})
|
})
|
||||||
.expect("failed to open window");
|
.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,
|
||||||
|
)]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
+107
-112
@@ -1,10 +1,6 @@
|
|||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use gpui::{Anchor, App, Context, IntoElement, SharedString, Window, px};
|
use gpui::{App, Context, SharedString, Window, px};
|
||||||
use gpui_component::{
|
use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry};
|
||||||
ActiveTheme as _, IconName, Sizable as _, Theme, ThemeMode, ThemeRegistry,
|
|
||||||
button::{Button, ButtonVariants as _},
|
|
||||||
menu::{DropdownMenu as _, PopupMenuItem},
|
|
||||||
};
|
|
||||||
use rust_i18n::t;
|
use rust_i18n::t;
|
||||||
|
|
||||||
use crate::Ashell;
|
use crate::Ashell;
|
||||||
@@ -14,16 +10,76 @@ pub(crate) const EMBEDDED_THEME_JSONS: &[&str] = &[
|
|||||||
include_str!("../../assets/themes/tokyonight.json"),
|
include_str!("../../assets/themes/tokyonight.json"),
|
||||||
include_str!("../../assets/themes/gruvbox.json"),
|
include_str!("../../assets/themes/gruvbox.json"),
|
||||||
include_str!("../../assets/themes/solarized.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<()> {
|
pub(crate) fn load_fonts(cx: &mut App) -> Result<()> {
|
||||||
let regular =
|
let has_system_maple = cx
|
||||||
std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf").as_slice());
|
.text_system()
|
||||||
let bold = std::borrow::Cow::Borrowed(include_bytes!("../../assets/fonts/MapleMono-NF-CN-Bold.ttf").as_slice());
|
.all_font_names()
|
||||||
cx.text_system()
|
.contains(&"Maple Mono NF CN".to_string());
|
||||||
.add_fonts(vec![regular, bold])
|
if has_system_maple {
|
||||||
.context("load Maple Mono NF CN fonts")?;
|
USING_SYSTEM_MAPLE.store(true, Ordering::Relaxed);
|
||||||
set_theme_font_names(cx.global_mut::<Theme>(), ".SystemUIFont");
|
} 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +98,12 @@ pub(crate) fn set_theme_font_names(theme: &mut Theme, ui_font_family: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Ashell {
|
impl Ashell {
|
||||||
pub(crate) fn switch_theme_mode(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context<Self>) {
|
pub(crate) fn switch_theme_mode(
|
||||||
|
&mut self,
|
||||||
|
mode: ThemeMode,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
self.follow_system_theme = false;
|
self.follow_system_theme = false;
|
||||||
self.theme_mode = mode;
|
self.theme_mode = mode;
|
||||||
self.apply_theme_preferences(window, cx);
|
self.apply_theme_preferences(window, cx);
|
||||||
@@ -51,7 +112,12 @@ impl Ashell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn apply_theme(&mut self, name: SharedString, window: &mut Window, cx: &mut Context<Self>) {
|
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 {
|
let Some(theme_config) = ThemeRegistry::global(cx).themes().get(&name).cloned() else {
|
||||||
self.status = format!("theme not found: {name}").into();
|
self.status = format!("theme not found: {name}").into();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -87,7 +153,12 @@ impl Ashell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_display_language(&mut self, locale: &str, window: &mut Window, cx: &mut Context<Self>) {
|
pub(crate) fn set_display_language(
|
||||||
|
&mut self,
|
||||||
|
locale: &str,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
self.config.set_locale(locale);
|
self.config.set_locale(locale);
|
||||||
let mut active_locale = locale.to_string();
|
let mut active_locale = locale.to_string();
|
||||||
if active_locale == "system" {
|
if active_locale == "system" {
|
||||||
@@ -100,9 +171,23 @@ impl Ashell {
|
|||||||
}
|
}
|
||||||
rust_i18n::set_locale(&active_locale);
|
rust_i18n::set_locale(&active_locale);
|
||||||
gpui_component::set_locale(&active_locale);
|
gpui_component::set_locale(&active_locale);
|
||||||
if let Err(err) = self.config.save() {
|
crate::app::system_menu::set_app_menus(cx);
|
||||||
tracing::warn!("failed to save language preferences: {err:#}");
|
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();
|
window.refresh();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
@@ -118,11 +203,12 @@ impl Ashell {
|
|||||||
.get(&self.dark_theme_name)
|
.get(&self.dark_theme_name)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| ThemeRegistry::global(cx).default_dark_theme().clone());
|
.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);
|
let theme = Theme::global_mut(cx);
|
||||||
theme.light_theme = light_theme;
|
theme.light_theme = light_theme;
|
||||||
theme.dark_theme = dark_theme;
|
theme.dark_theme = dark_theme;
|
||||||
theme.font_size = px(self.ui_font_size);
|
theme.font_size = px(self.ui_font_size);
|
||||||
set_theme_font_names(theme, &self.ui_font_family);
|
set_theme_font_names(theme, &safe_font);
|
||||||
|
|
||||||
if self.follow_system_theme {
|
if self.follow_system_theme {
|
||||||
Theme::sync_system_appearance(Some(window), cx);
|
Theme::sync_system_appearance(Some(window), cx);
|
||||||
@@ -142,97 +228,6 @@ impl Ashell {
|
|||||||
self.light_theme_name.to_string(),
|
self.light_theme_name.to_string(),
|
||||||
self.dark_theme_name.to_string(),
|
self.dark_theme_name.to_string(),
|
||||||
);
|
);
|
||||||
if let Err(err) = self.config.save() {
|
self.save_preferences_background();
|
||||||
tracing::warn!("failed to save theme preferences: {err:#}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn theme_dropdown(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
||||||
let view = cx.entity();
|
|
||||||
let themes = ThemeRegistry::global(cx)
|
|
||||||
.sorted_themes()
|
|
||||||
.into_iter()
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let light_themes = themes
|
|
||||||
.iter()
|
|
||||||
.filter(|theme| !theme.mode.is_dark())
|
|
||||||
.map(|theme| theme.name.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let dark_themes = themes
|
|
||||||
.iter()
|
|
||||||
.filter(|theme| theme.mode.is_dark())
|
|
||||||
.map(|theme| theme.name.clone())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let follow_system = self.follow_system_theme;
|
|
||||||
let is_dark_mode = cx.theme().mode.is_dark();
|
|
||||||
let light_theme_name = self.light_theme_name.clone();
|
|
||||||
let dark_theme_name = self.dark_theme_name.clone();
|
|
||||||
let icon = if follow_system {
|
|
||||||
IconName::Sun
|
|
||||||
} else if is_dark_mode {
|
|
||||||
IconName::Moon
|
|
||||||
} else {
|
|
||||||
IconName::Sun
|
|
||||||
};
|
|
||||||
|
|
||||||
Button::new("theme-dropdown")
|
|
||||||
.ghost()
|
|
||||||
.small()
|
|
||||||
.icon(icon)
|
|
||||||
.dropdown_menu_with_anchor(Anchor::BottomRight, move |mut menu, window, _| {
|
|
||||||
menu = menu
|
|
||||||
.min_w(220.)
|
|
||||||
.item(
|
|
||||||
PopupMenuItem::new(t!("follow_system"))
|
|
||||||
.checked(follow_system)
|
|
||||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
|
||||||
this.set_follow_system_theme(true, window, cx)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.item(
|
|
||||||
PopupMenuItem::new(t!("use_light_mode"))
|
|
||||||
.checked(!follow_system && !is_dark_mode)
|
|
||||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
|
||||||
this.switch_theme_mode(ThemeMode::Light, window, cx)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.item(
|
|
||||||
PopupMenuItem::new(t!("use_dark_mode"))
|
|
||||||
.checked(!follow_system && is_dark_mode)
|
|
||||||
.on_click(window.listener_for(&view, |this, _, window, cx| {
|
|
||||||
this.switch_theme_mode(ThemeMode::Dark, window, cx)
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.separator()
|
|
||||||
.label(t!("light_theme").to_string());
|
|
||||||
|
|
||||||
for theme_name in light_themes.clone() {
|
|
||||||
let checked = theme_name == light_theme_name;
|
|
||||||
menu = menu.item(
|
|
||||||
PopupMenuItem::new(theme_name.clone())
|
|
||||||
.checked(checked)
|
|
||||||
.on_click(window.listener_for(&view, move |this, _, window, cx| {
|
|
||||||
this.apply_theme(theme_name.clone(), window, cx)
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
menu = menu.separator();
|
|
||||||
menu = menu.label(t!("dark_theme").to_string());
|
|
||||||
|
|
||||||
for theme_name in dark_themes.clone() {
|
|
||||||
let checked = theme_name == dark_theme_name;
|
|
||||||
menu = menu.item(
|
|
||||||
PopupMenuItem::new(theme_name.clone())
|
|
||||||
.checked(checked)
|
|
||||||
.on_click(window.listener_for(&view, move |this, _, window, cx| {
|
|
||||||
this.apply_theme(theme_name.clone(), window, cx)
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
menu
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4996
-1044
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+644
-30
@@ -1,19 +1,480 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
fs,
|
||||||
io::{Read, Write},
|
io::{Read, Write},
|
||||||
sync::mpsc::{self, Sender},
|
path::{Path, PathBuf},
|
||||||
|
sync::{OnceLock, mpsc},
|
||||||
thread,
|
thread,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
|
||||||
|
|
||||||
use crate::terminal::{BackendCommand, BackendEvent, BackendTx};
|
use crate::session::config::LocalTerminalShell;
|
||||||
|
use crate::terminal::{BackendCommand, BackendEvent, BackendTx, GuardedBackendEventSender};
|
||||||
|
|
||||||
pub fn spawn_local_terminal(
|
#[cfg(not(windows))]
|
||||||
|
const DIRECTORY_POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
const BASH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
|
||||||
|
if [[ ${ASHELL_BASH_LOGIN:-0} == 1 ]]; then
|
||||||
|
[[ -r /etc/profile ]] && source /etc/profile
|
||||||
|
if [[ -r $HOME/.bash_profile ]]; then
|
||||||
|
source "$HOME/.bash_profile"
|
||||||
|
elif [[ -r $HOME/.bash_login ]]; then
|
||||||
|
source "$HOME/.bash_login"
|
||||||
|
elif [[ -r $HOME/.profile ]]; then
|
||||||
|
source "$HOME/.profile"
|
||||||
|
fi
|
||||||
|
elif [[ -r $HOME/.bashrc ]]; then
|
||||||
|
source "$HOME/.bashrc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${__ASHELL_BASH_INTEGRATION_ACTIVE:-} ]]; then
|
||||||
|
__ASHELL_BASH_INTEGRATION_ACTIVE=1
|
||||||
|
__ashell_prompt_suffix='\[\e]133;B\a\]'
|
||||||
|
__ashell_secondary_prefix='\[\e]133;A;k=s\a\]'
|
||||||
|
|
||||||
|
__ashell_install_prompt_markers() {
|
||||||
|
PS1=${PS1//"$__ashell_prompt_suffix"/}
|
||||||
|
PS1+=$__ashell_prompt_suffix
|
||||||
|
PS2=${PS2//"$__ashell_secondary_prefix"/}
|
||||||
|
PS2=${PS2//"$__ashell_prompt_suffix"/}
|
||||||
|
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
|
||||||
|
}
|
||||||
|
|
||||||
|
__ashell_precmd() {
|
||||||
|
local __ashell_last_status=$?
|
||||||
|
local __ashell_cwd=$PWD
|
||||||
|
__ashell_install_prompt_markers
|
||||||
|
if [[ ${ASHELL_WINDOWS_GIT_BASH:-0} == 1 ]]; then
|
||||||
|
__ashell_cwd=$(pwd -W 2>/dev/null) || __ashell_cwd=$PWD
|
||||||
|
fi
|
||||||
|
printf '\033]133;D;%s\007\033]0;ASHELL_CWD:%s\007\033]133;A\007' \
|
||||||
|
"$__ashell_last_status" "$__ashell_cwd"
|
||||||
|
return "$__ashell_last_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
case $(declare -p PROMPT_COMMAND 2>/dev/null) in
|
||||||
|
'declare -a'*)
|
||||||
|
PROMPT_COMMAND+=(__ashell_precmd)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
[[ -n ${PROMPT_COMMAND:-} ]] && PROMPT_COMMAND+=$'\n'
|
||||||
|
PROMPT_COMMAND+='__ashell_precmd'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
__ashell_install_prompt_markers
|
||||||
|
fi
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const ZSH_ENV_WRAPPER: &str = r#"# Generated by ashell. Redirect startup back to the user's files first.
|
||||||
|
typeset -g ASHELL_USER_ZDOTDIR="${ASHELL_ORIGINAL_ZDOTDIR:-$HOME}"
|
||||||
|
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshenv" ]]; then
|
||||||
|
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
|
||||||
|
source "$ASHELL_USER_ZDOTDIR/.zshenv"
|
||||||
|
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
|
||||||
|
fi
|
||||||
|
export ASHELL_USER_ZDOTDIR
|
||||||
|
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const ZSH_INTEGRATION_RC: &str = r#"# Generated by ashell. User startup files remain authoritative.
|
||||||
|
if [[ ${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/* ]]; then
|
||||||
|
HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$ASHELL_USER_ZDOTDIR" != "$ASHELL_INTEGRATION_ZDOTDIR" && -r "$ASHELL_USER_ZDOTDIR/.zshrc" ]]; then
|
||||||
|
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
|
||||||
|
source "$ASHELL_USER_ZDOTDIR/.zshrc"
|
||||||
|
typeset -g ASHELL_USER_ZDOTDIR="${ZDOTDIR:-$ASHELL_USER_ZDOTDIR}"
|
||||||
|
export ZDOTDIR="$ASHELL_INTEGRATION_ZDOTDIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z ${__ASHELL_ZSH_INTEGRATION_ACTIVE:-} ]]; then
|
||||||
|
typeset -g __ASHELL_ZSH_INTEGRATION_ACTIVE=1
|
||||||
|
typeset -g __ashell_prompt_suffix=$'%{\e]133;B\a%}'
|
||||||
|
typeset -g __ashell_secondary_prefix=$'%{\e]133;A;k=s\a%}'
|
||||||
|
|
||||||
|
__ashell_install_prompt_markers() {
|
||||||
|
PROMPT=${PROMPT//$__ashell_prompt_suffix/}
|
||||||
|
PROMPT+=$__ashell_prompt_suffix
|
||||||
|
PS2=${PS2//$__ashell_secondary_prefix/}
|
||||||
|
PS2=${PS2//$__ashell_prompt_suffix/}
|
||||||
|
PS2="${__ashell_secondary_prefix}${PS2}${__ashell_prompt_suffix}"
|
||||||
|
}
|
||||||
|
|
||||||
|
__ashell_precmd() {
|
||||||
|
local __ashell_last_status=$?
|
||||||
|
__ashell_install_prompt_markers
|
||||||
|
print -rn -- $'\e]133;D;'"$__ashell_last_status"$'\a'
|
||||||
|
print -rn -- $'\e]0;ASHELL_CWD:'"$PWD"$'\a'
|
||||||
|
print -rn -- $'\e]133;A\a'
|
||||||
|
return "$__ashell_last_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
__ashell_preexec() {
|
||||||
|
print -rn -- $'\e]133;C\a'
|
||||||
|
}
|
||||||
|
|
||||||
|
autoload -Uz add-zsh-hook
|
||||||
|
add-zsh-hook -d precmd __ashell_precmd 2>/dev/null
|
||||||
|
add-zsh-hook -d preexec __ashell_preexec 2>/dev/null
|
||||||
|
add-zsh-hook precmd __ashell_precmd
|
||||||
|
add-zsh-hook preexec __ashell_preexec
|
||||||
|
__ashell_install_prompt_markers
|
||||||
|
fi
|
||||||
|
export ZDOTDIR="$ASHELL_USER_ZDOTDIR"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const FISH_INTEGRATION_COMMAND: &str = r#"
|
||||||
|
if status --is-interactive; and not functions -q __ashell_original_fish_prompt
|
||||||
|
functions -c fish_prompt __ashell_original_fish_prompt
|
||||||
|
|
||||||
|
function __ashell_common_prompt
|
||||||
|
set -l __ashell_last_status $status
|
||||||
|
printf '\033]133;D;%s\007' $__ashell_last_status
|
||||||
|
printf '\033]0;ASHELL_CWD:%s\007' $PWD
|
||||||
|
printf '\033]133;A\007'
|
||||||
|
return $__ashell_last_status
|
||||||
|
end
|
||||||
|
|
||||||
|
function __ashell_preexec --on-event fish_preexec
|
||||||
|
printf '\033]133;C\007'
|
||||||
|
end
|
||||||
|
|
||||||
|
function fish_prompt
|
||||||
|
__ashell_common_prompt
|
||||||
|
__ashell_original_fish_prompt
|
||||||
|
printf '\033]133;B\007'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
const POWERSHELL_SHELL_INTEGRATION: &str = r#"& {
|
||||||
|
$global:AshellOriginalPrompt = $function:prompt
|
||||||
|
function global:prompt {
|
||||||
|
$promptText = if ($global:AshellOriginalPrompt) { & $global:AshellOriginalPrompt } else { "PS $PWD> " }
|
||||||
|
$cwd = $PWD.ProviderPath
|
||||||
|
$encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cwd))
|
||||||
|
[Console]::Write("$([char]27)]133;D$([char]7)$([char]27)]133;A$([char]7)")
|
||||||
|
[Console]::Write("$([char]27)]0;ASHELL_CWD_B64:$encoded$([char]7)")
|
||||||
|
"$promptText$([char]27)]133;B$([char]7)"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$options = Get-PSReadLineOption -ErrorAction Stop
|
||||||
|
$continuation = "$([char]27)]133;A;k=s$([char]7)$($options.ContinuationPrompt)$([char]27)]133;B$([char]7)"
|
||||||
|
Set-PSReadLineOption -ContinuationPrompt $continuation -ErrorAction Stop
|
||||||
|
} catch {}
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ShellIntegrationPaths {
|
||||||
|
bash_rc: PathBuf,
|
||||||
|
zsh_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FileShellIntegration {
|
||||||
|
Bash { login: bool },
|
||||||
|
Zsh,
|
||||||
|
Fish,
|
||||||
|
}
|
||||||
|
|
||||||
|
static SHELL_INTEGRATION_PATHS: OnceLock<Result<ShellIntegrationPaths, String>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn file_shell_integration(executable: &Path, login_bash: bool) -> Option<FileShellIntegration> {
|
||||||
|
let executable_name = executable
|
||||||
|
.file_stem()?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
match executable_name.as_str() {
|
||||||
|
"bash" => Some(FileShellIntegration::Bash { login: login_bash }),
|
||||||
|
"zsh" => Some(FileShellIntegration::Zsh),
|
||||||
|
"fish" => Some(FileShellIntegration::Fish),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_integration_paths() -> Result<&'static ShellIntegrationPaths> {
|
||||||
|
SHELL_INTEGRATION_PATHS
|
||||||
|
.get_or_init(|| create_shell_integration_paths().map_err(|error| format!("{error:#}")))
|
||||||
|
.as_ref()
|
||||||
|
.map_err(|message| anyhow::anyhow!("{message}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_shell_integration_paths() -> Result<ShellIntegrationPaths> {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"ashell-shell-integration-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let zsh_dir = root.join("zsh");
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::DirBuilderExt;
|
||||||
|
|
||||||
|
fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(&zsh_dir)
|
||||||
|
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fs::create_dir_all(&zsh_dir)
|
||||||
|
.with_context(|| format!("create shell integration directory {}", zsh_dir.display()))?;
|
||||||
|
|
||||||
|
let bash_rc = root.join("bashrc");
|
||||||
|
write_shell_integration_file(&bash_rc, BASH_INTEGRATION_RC)?;
|
||||||
|
write_shell_integration_file(&zsh_dir.join(".zshenv"), ZSH_ENV_WRAPPER)?;
|
||||||
|
write_shell_integration_file(&zsh_dir.join(".zshrc"), ZSH_INTEGRATION_RC)?;
|
||||||
|
|
||||||
|
Ok(ShellIntegrationPaths { bash_rc, zsh_dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_shell_integration_file(path: &Path, contents: &str) -> Result<()> {
|
||||||
|
let mut options = fs::OpenOptions::new();
|
||||||
|
options.write(true).create_new(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut file = options
|
||||||
|
.open(path)
|
||||||
|
.with_context(|| format!("create shell integration file {}", path.display()))?;
|
||||||
|
file.write_all(contents.as_bytes())
|
||||||
|
.with_context(|| format!("write shell integration file {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_file_shell_integration(
|
||||||
|
cmd: &mut CommandBuilder,
|
||||||
|
executable: &Path,
|
||||||
|
login_bash: bool,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let Some(integration) = file_shell_integration(executable, login_bash) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
match integration {
|
||||||
|
FileShellIntegration::Bash { login } => {
|
||||||
|
let paths = shell_integration_paths()?;
|
||||||
|
cmd.env("ASHELL_BASH_LOGIN", if login { "1" } else { "0" });
|
||||||
|
cmd.env("ASHELL_WINDOWS_GIT_BASH", if login { "1" } else { "0" });
|
||||||
|
cmd.arg("--rcfile");
|
||||||
|
cmd.arg(paths.bash_rc.as_os_str());
|
||||||
|
cmd.arg("-i");
|
||||||
|
}
|
||||||
|
FileShellIntegration::Zsh => {
|
||||||
|
let paths = shell_integration_paths()?;
|
||||||
|
if let Some(original_zdotdir) = std::env::var_os("ZDOTDIR") {
|
||||||
|
cmd.env("ASHELL_ORIGINAL_ZDOTDIR", original_zdotdir);
|
||||||
|
}
|
||||||
|
cmd.env("ASHELL_INTEGRATION_ZDOTDIR", paths.zsh_dir.as_os_str());
|
||||||
|
cmd.env("ZDOTDIR", paths.zsh_dir.as_os_str());
|
||||||
|
}
|
||||||
|
FileShellIntegration::Fish => {
|
||||||
|
cmd.args(["-C", FISH_INTEGRATION_COMMAND]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.env("ASHELL_SHELL_INTEGRATION", "1");
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn command_prompt_with_shell_integration(original_prompt: &str) -> String {
|
||||||
|
format!("\x1b]133;D\x07\x1b]133;A\x07\x1b]0;ASHELL_CWD:$P\x07{original_prompt}\x1b]133;B\x07")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn local_process_directory(system: &mut System, pid: Pid) -> Option<std::path::PathBuf> {
|
||||||
|
system.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[pid]),
|
||||||
|
false,
|
||||||
|
ProcessRefreshKind::nothing().with_cwd(UpdateKind::Always),
|
||||||
|
);
|
||||||
|
system
|
||||||
|
.process(pid)
|
||||||
|
.and_then(|process| process.cwd())
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LocalTerminalShellLaunch {
|
||||||
|
pub executable: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a configured local shell to an executable available on this host.
|
||||||
|
pub fn resolve_local_terminal_shell(
|
||||||
|
shell: LocalTerminalShell,
|
||||||
|
) -> anyhow::Result<LocalTerminalShellLaunch> {
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
let _ = shell;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let executable = match shell {
|
||||||
|
LocalTerminalShell::WindowsPowerShell => resolve_windows_powershell(),
|
||||||
|
LocalTerminalShell::PowerShell7 => resolve_powershell7(),
|
||||||
|
LocalTerminalShell::CommandPrompt => resolve_command_prompt(),
|
||||||
|
LocalTerminalShell::GitBash => resolve_git_bash(),
|
||||||
|
}
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("configured local shell is not installed: {shell:?}"))?;
|
||||||
|
return Ok(LocalTerminalShellLaunch { executable });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
let executable = std::env::var_os("SHELL")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/bin/zsh"));
|
||||||
|
Ok(LocalTerminalShellLaunch { executable })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_terminal_shell_available(shell: LocalTerminalShell) -> bool {
|
||||||
|
resolve_local_terminal_shell(shell).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn existing_file(path: PathBuf) -> Option<PathBuf> {
|
||||||
|
path.is_file().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn env_path(name: &str) -> Option<PathBuf> {
|
||||||
|
std::env::var_os(name).map(PathBuf::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn find_on_path(name: &str, predicate: impl Fn(&Path) -> bool) -> Option<PathBuf> {
|
||||||
|
let path = std::env::var_os("PATH")?;
|
||||||
|
std::env::split_paths(&path)
|
||||||
|
.map(|directory| directory.join(name))
|
||||||
|
.find(|candidate| candidate.is_file() && predicate(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn resolve_windows_powershell() -> Option<PathBuf> {
|
||||||
|
let system_root = env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
|
||||||
|
existing_file(system_root.join(r"System32\WindowsPowerShell\v1.0\powershell.exe"))
|
||||||
|
.or_else(|| find_on_path("powershell.exe", |_| true))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn resolve_powershell7() -> Option<PathBuf> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
if let Some(program_files) = env_path("ProgramFiles") {
|
||||||
|
roots.push(program_files.join("PowerShell"));
|
||||||
|
}
|
||||||
|
if let Some(program_files) = env_path("ProgramW6432") {
|
||||||
|
roots.push(program_files.join("PowerShell"));
|
||||||
|
}
|
||||||
|
if let Some(program_files) = env_path("ProgramFiles(x86)") {
|
||||||
|
roots.push(program_files.join("PowerShell"));
|
||||||
|
}
|
||||||
|
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
|
||||||
|
roots.push(local_app_data.join(r"Programs\PowerShell"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
|
||||||
|
candidates.push(local_app_data.join(r"Microsoft\WindowsApps\pwsh.exe"));
|
||||||
|
}
|
||||||
|
if let Some(scoop) = env_path("SCOOP") {
|
||||||
|
candidates.push(scoop.join(r"apps\powershell\current\pwsh.exe"));
|
||||||
|
}
|
||||||
|
if let Some(user_profile) = env_path("USERPROFILE") {
|
||||||
|
candidates.push(user_profile.join(r"scoop\apps\powershell\current\pwsh.exe"));
|
||||||
|
}
|
||||||
|
for root in roots {
|
||||||
|
if let Some(candidate) = existing_file(root.join("pwsh.exe")) {
|
||||||
|
candidates.push(candidate);
|
||||||
|
}
|
||||||
|
let Ok(entries) = std::fs::read_dir(root) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut versions = entries
|
||||||
|
.filter_map(|entry| entry.ok())
|
||||||
|
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_string_lossy()
|
||||||
|
.chars()
|
||||||
|
.all(|character| character.is_ascii_digit() || character == '.')
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
versions.sort_by_key(|entry| entry.file_name());
|
||||||
|
candidates.extend(
|
||||||
|
versions
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.filter_map(|entry| existing_file(entry.path().join("pwsh.exe"))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.or_else(|| find_on_path("pwsh.exe", |_| true))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn resolve_command_prompt() -> Option<PathBuf> {
|
||||||
|
env_path("ComSpec")
|
||||||
|
.and_then(existing_file)
|
||||||
|
.or_else(|| {
|
||||||
|
let system_root =
|
||||||
|
env_path("SystemRoot").unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
|
||||||
|
existing_file(system_root.join(r"System32\cmd.exe"))
|
||||||
|
})
|
||||||
|
.or_else(|| find_on_path("cmd.exe", |_| true))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn resolve_git_bash() -> Option<PathBuf> {
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for variable in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
|
||||||
|
if let Some(program_files) = env_path(variable) {
|
||||||
|
candidates.push(program_files.join(r"Git\bin\bash.exe"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(local_app_data) = env_path("LOCALAPPDATA") {
|
||||||
|
candidates.push(local_app_data.join(r"Programs\Git\bin\bash.exe"));
|
||||||
|
}
|
||||||
|
if let Some(scoop) = env_path("SCOOP") {
|
||||||
|
candidates.push(scoop.join(r"apps\git\current\bin\bash.exe"));
|
||||||
|
}
|
||||||
|
if let Some(user_profile) = env_path("USERPROFILE") {
|
||||||
|
candidates.push(user_profile.join(r"scoop\apps\git\current\bin\bash.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.into_iter().find_map(existing_file).or_else(|| {
|
||||||
|
find_on_path("bash.exe", |candidate| {
|
||||||
|
let path = candidate.to_string_lossy().to_ascii_lowercase();
|
||||||
|
path.contains("\\git\\") || path.contains("/git/")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_local_terminal_at(
|
||||||
tab_id: String,
|
tab_id: String,
|
||||||
cols: u16,
|
cols: u16,
|
||||||
rows: u16,
|
rows: u16,
|
||||||
events: Sender<BackendEvent>,
|
events: GuardedBackendEventSender,
|
||||||
|
initial_directory: Option<&Path>,
|
||||||
|
shell: LocalTerminalShell,
|
||||||
) -> Result<BackendTx> {
|
) -> Result<BackendTx> {
|
||||||
let pty_system = native_pty_system();
|
let pty_system = native_pty_system();
|
||||||
let pair = pty_system
|
let pair = pty_system
|
||||||
@@ -25,15 +486,51 @@ pub fn spawn_local_terminal(
|
|||||||
})
|
})
|
||||||
.context("open local PTY")?;
|
.context("open local PTY")?;
|
||||||
|
|
||||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
|
let launch = resolve_local_terminal_shell(shell).context("resolve local shell")?;
|
||||||
if cfg!(windows) {
|
let mut cmd = CommandBuilder::new(&launch.executable);
|
||||||
"powershell.exe".into()
|
#[cfg(windows)]
|
||||||
} else {
|
{
|
||||||
"/bin/zsh".into()
|
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 mut cmd = CommandBuilder::new(&shell);
|
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(
|
cmd.env(
|
||||||
"TERM",
|
"TERM",
|
||||||
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
|
std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".into()),
|
||||||
@@ -54,8 +551,13 @@ pub fn spawn_local_terminal(
|
|||||||
if let Ok(home) = std::env::var("HOME") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
cmd.env("HOME", home);
|
cmd.env("HOME", home);
|
||||||
}
|
}
|
||||||
cmd.env("SHELL", shell);
|
if let Some(directory) = initial_directory.filter(|path| path.is_dir()) {
|
||||||
|
cmd.cwd(directory.as_os_str());
|
||||||
|
}
|
||||||
|
cmd.env("SHELL", launch.executable.as_os_str());
|
||||||
let mut child = pair.slave.spawn_command(cmd).context("spawn local shell")?;
|
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);
|
drop(pair.slave);
|
||||||
|
|
||||||
let master = pair.master;
|
let master = pair.master;
|
||||||
@@ -94,28 +596,70 @@ pub fn spawn_local_terminal(
|
|||||||
let write_tab = tab_id.clone();
|
let write_tab = tab_id.clone();
|
||||||
let write_events = events.clone();
|
let write_events = events.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
while let Ok(command) = cmd_rx.recv() {
|
#[cfg(not(windows))]
|
||||||
match command {
|
let mut process_system = System::new();
|
||||||
BackendCommand::Input(bytes) => {
|
#[cfg(not(windows))]
|
||||||
if let Err(err) = writer.write_all(&bytes) {
|
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 {
|
let _ = write_events.send(BackendEvent::Closed {
|
||||||
tab_id: write_tab.clone(),
|
tab_id: write_tab,
|
||||||
reason: format!("local write error: {err}"),
|
reason: format!("local shell exited: {status}"),
|
||||||
});
|
});
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
let _ = writer.flush();
|
|
||||||
}
|
}
|
||||||
BackendCommand::Resize { cols, rows } => {
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
let _ = master.resize(PtySize {
|
}
|
||||||
rows,
|
|
||||||
cols,
|
#[cfg(not(windows))]
|
||||||
pixel_width: 0,
|
{
|
||||||
pixel_height: 0,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
BackendCommand::Close => break,
|
|
||||||
BackendCommand::SampleMetrics => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
@@ -128,3 +672,73 @@ pub fn spawn_local_terminal(
|
|||||||
|
|
||||||
Ok(BackendTx::Local(cmd_tx))
|
Ok(BackendTx::Local(cmd_tx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
BASH_INTEGRATION_RC, FISH_INTEGRATION_COMMAND, FileShellIntegration,
|
||||||
|
POWERSHELL_SHELL_INTEGRATION, ZSH_ENV_WRAPPER, ZSH_INTEGRATION_RC,
|
||||||
|
command_prompt_with_shell_integration, file_shell_integration,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognizes_supported_file_based_shells_on_unix_and_windows() {
|
||||||
|
assert_eq!(
|
||||||
|
file_shell_integration(Path::new("/bin/bash"), false),
|
||||||
|
Some(FileShellIntegration::Bash { login: false })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file_shell_integration(Path::new("bash.exe"), true),
|
||||||
|
Some(FileShellIntegration::Bash { login: true })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file_shell_integration(Path::new("/bin/zsh"), false),
|
||||||
|
Some(FileShellIntegration::Zsh)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
file_shell_integration(Path::new("/opt/homebrew/bin/fish"), false),
|
||||||
|
Some(FileShellIntegration::Fish)
|
||||||
|
);
|
||||||
|
assert_eq!(file_shell_integration(Path::new("pwsh.exe"), false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_integrations_mark_prompt_and_command_boundaries() {
|
||||||
|
for integration in [
|
||||||
|
BASH_INTEGRATION_RC,
|
||||||
|
ZSH_INTEGRATION_RC,
|
||||||
|
FISH_INTEGRATION_COMMAND,
|
||||||
|
POWERSHELL_SHELL_INTEGRATION,
|
||||||
|
] {
|
||||||
|
assert!(integration.contains("133;A"));
|
||||||
|
assert!(integration.contains("133;B"));
|
||||||
|
assert!(integration.contains("133;C") || integration.contains("133;D"));
|
||||||
|
}
|
||||||
|
assert!(BASH_INTEGRATION_RC.contains("133;A;k=s"));
|
||||||
|
assert!(ZSH_INTEGRATION_RC.contains("133;A;k=s"));
|
||||||
|
assert!(POWERSHELL_SHELL_INTEGRATION.contains("133;A;k=s"));
|
||||||
|
|
||||||
|
let cmd_prompt = command_prompt_with_shell_integration("$P$G");
|
||||||
|
assert!(cmd_prompt.contains("133;A"));
|
||||||
|
assert!(cmd_prompt.contains("133;B"));
|
||||||
|
assert!(cmd_prompt.contains("ASHELL_CWD:$P"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_wrappers_load_user_configuration_before_installing_hooks() {
|
||||||
|
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bashrc"));
|
||||||
|
assert!(BASH_INTEGRATION_RC.contains("$HOME/.bash_profile"));
|
||||||
|
assert!(ZSH_ENV_WRAPPER.contains("$ASHELL_USER_ZDOTDIR/.zshenv"));
|
||||||
|
assert!(ZSH_INTEGRATION_RC.contains("$ASHELL_USER_ZDOTDIR/.zshrc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zsh_integration_restores_user_history_path() {
|
||||||
|
assert!(ZSH_INTEGRATION_RC.contains(r#"${HISTFILE:-} == "$ASHELL_INTEGRATION_ZDOTDIR"/*"#));
|
||||||
|
assert!(ZSH_INTEGRATION_RC.contains(
|
||||||
|
r#"HISTFILE="$ASHELL_USER_ZDOTDIR/${HISTFILE#"$ASHELL_INTEGRATION_ZDOTDIR"/}""#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod local;
|
pub mod local;
|
||||||
|
pub mod serial;
|
||||||
pub mod ssh;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+760
-121
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
|||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
use std::{collections::VecDeque, sync::Mutex};
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const APP_BUNDLE_ID: &str = "dev.ashell.app";
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const APP_USER_MODEL_ID: &str = "dev.ashell.app";
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
const MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS: usize = 64;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
static TERMINAL_NOTIFICATION_ACTIVATIONS: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());
|
||||||
|
|
||||||
|
pub(crate) fn initialize() {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
match desktop_notify::set_application(APP_BUNDLE_ID) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!("registered the macOS notification application identity {APP_BUNDLE_ID}")
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"failed to register the macOS notification application identity {APP_BUNDLE_ID}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Err(error) = unsafe {
|
||||||
|
windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID(windows::core::w!(
|
||||||
|
"dev.ashell.app"
|
||||||
|
))
|
||||||
|
} {
|
||||||
|
tracing::warn!("failed to set Windows application identity: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn show_terminal_notification(tab_id: String, title: String, body: String) {
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
if let Err(error) = std::thread::Builder::new()
|
||||||
|
.name("desktop-notification".to_string())
|
||||||
|
.spawn(move || show_native_notification(&title, &body, &tab_id))
|
||||||
|
{
|
||||||
|
tracing::warn!("failed to start desktop notification thread: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
{
|
||||||
|
let _ = (tab_id, title, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
|
||||||
|
let result = mac_notification_sys::Notification::new()
|
||||||
|
.title(title)
|
||||||
|
.message(body)
|
||||||
|
.wait_for_click(true)
|
||||||
|
.send();
|
||||||
|
match result {
|
||||||
|
Ok(mac_notification_sys::NotificationResponse::Click) => {
|
||||||
|
queue_terminal_notification_activation(tab_id.to_string());
|
||||||
|
}
|
||||||
|
Ok(_) => tracing::info!("terminal notification closed without activation on macOS"),
|
||||||
|
Err(error) => tracing::warn!("failed to show macOS terminal notification: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn show_native_notification(title: &str, body: &str, tab_id: &str) {
|
||||||
|
match show_windows_notification(APP_USER_MODEL_ID, title, body, tab_id) {
|
||||||
|
Ok(()) => tracing::info!("submitted terminal notification to Windows"),
|
||||||
|
Err(primary_error) => {
|
||||||
|
if let Err(fallback_error) = show_windows_notification(
|
||||||
|
winrt_notification::Toast::POWERSHELL_APP_ID,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
tab_id,
|
||||||
|
) {
|
||||||
|
tracing::warn!(
|
||||||
|
"failed to show Windows terminal notification: {primary_error}; fallback failed: {fallback_error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn show_windows_notification(
|
||||||
|
app_id: &str,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
tab_id: &str,
|
||||||
|
) -> winrt_notification::Result<()> {
|
||||||
|
let activation_tab_id = tab_id.to_string();
|
||||||
|
winrt_notification::Toast::new(app_id)
|
||||||
|
.title(title)
|
||||||
|
.text2(body)
|
||||||
|
.duration(winrt_notification::Duration::Short)
|
||||||
|
.sound(None)
|
||||||
|
.on_activated(move || {
|
||||||
|
queue_terminal_notification_activation(activation_tab_id.clone());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
fn queue_terminal_notification_activation(tab_id: String) {
|
||||||
|
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
|
||||||
|
tracing::warn!("failed to lock terminal notification activation queue");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if activations.len() >= MAX_PENDING_TERMINAL_NOTIFICATION_ACTIVATIONS {
|
||||||
|
activations.pop_front();
|
||||||
|
}
|
||||||
|
activations.push_back(tab_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
|
||||||
|
let Ok(mut activations) = TERMINAL_NOTIFICATION_ACTIVATIONS.lock() else {
|
||||||
|
tracing::warn!("failed to lock terminal notification activation queue");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
activations.pop_front()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
pub(crate) fn take_terminal_notification_activation() -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn native_window_handle(window: &gpui::Window) -> Option<isize> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use raw_window_handle::RawWindowHandle;
|
||||||
|
|
||||||
|
let handle = raw_window_handle::HasWindowHandle::window_handle(window).ok()?;
|
||||||
|
return match handle.as_raw() {
|
||||||
|
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
let _ = window;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_unread_indicator(unread: bool, window_handle: Option<isize>) {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::NSApplication;
|
||||||
|
use objc2_foundation::NSString;
|
||||||
|
|
||||||
|
let _ = window_handle;
|
||||||
|
let Some(main_thread) = MainThreadMarker::new() else {
|
||||||
|
tracing::warn!("attempted to update the Dock badge outside the main thread");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let application = NSApplication::sharedApplication(main_thread);
|
||||||
|
let dock_tile = application.dockTile();
|
||||||
|
let label = unread.then(|| NSString::from_str("\u{2022}"));
|
||||||
|
dock_tile.setBadgeLabel(label.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some(window_handle) = window_handle {
|
||||||
|
if let Err(error) = set_windows_taskbar_badge(window_handle, unread) {
|
||||||
|
tracing::warn!("failed to update Windows taskbar badge: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||||
|
let _ = (unread, window_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_unread_indicator(window_handle: Option<isize>) {
|
||||||
|
set_unread_indicator(false, window_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
|
/// Clears notifications delivered by the current application from Notification Center.
|
||||||
|
pub(crate) fn clear_current_app_delivered_notifications() {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
use objc2_foundation::NSUserNotificationCenter;
|
||||||
|
|
||||||
|
// The default center is scoped to this application, so other apps' notifications remain untouched.
|
||||||
|
NSUserNotificationCenter::defaultUserNotificationCenter().removeAllDeliveredNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
use windows::UI::Notifications::ToastNotificationManager;
|
||||||
|
|
||||||
|
if let Err(error) = ToastNotificationManager::History().and_then(|history| history.Clear())
|
||||||
|
{
|
||||||
|
tracing::warn!("failed to clear Windows notification history: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn set_windows_taskbar_badge(window_handle: isize, unread: bool) -> windows::core::Result<()> {
|
||||||
|
use windows::{
|
||||||
|
Win32::{
|
||||||
|
Foundation::HWND,
|
||||||
|
System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
||||||
|
UI::{
|
||||||
|
Shell::{ITaskbarList3, TaskbarList},
|
||||||
|
WindowsAndMessaging::{
|
||||||
|
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
core::{IUnknown, PCWSTR, w},
|
||||||
|
};
|
||||||
|
|
||||||
|
let taskbar: ITaskbarList3 =
|
||||||
|
unsafe { CoCreateInstance(&TaskbarList, None::<&IUnknown>, CLSCTX_INPROC_SERVER)? };
|
||||||
|
unsafe { taskbar.HrInit()? };
|
||||||
|
|
||||||
|
let hwnd = HWND(window_handle);
|
||||||
|
if !unread {
|
||||||
|
return unsafe { taskbar.SetOverlayIcon(hwnd, HICON(0), PCWSTR::null()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon_resource = windows_badge_icon_resource();
|
||||||
|
let icon = unsafe {
|
||||||
|
CreateIconFromResourceEx(&icon_resource, true, 0x0003_0000, 16, 16, LR_DEFAULTCOLOR)?
|
||||||
|
};
|
||||||
|
let result = unsafe { taskbar.SetOverlayIcon(hwnd, icon, w!("Unread terminal notification")) };
|
||||||
|
let destroy_result = unsafe { DestroyIcon(icon) };
|
||||||
|
result?;
|
||||||
|
destroy_result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn windows_badge_icon_resource() -> Vec<u8> {
|
||||||
|
const WIDTH: usize = 16;
|
||||||
|
const HEIGHT: usize = 16;
|
||||||
|
const BITMAP_HEADER_SIZE: u32 = 40;
|
||||||
|
const XOR_BYTES: usize = WIDTH * HEIGHT * 4;
|
||||||
|
const AND_ROW_BYTES: usize = 4;
|
||||||
|
|
||||||
|
let mut resource = Vec::with_capacity(BITMAP_HEADER_SIZE as usize + XOR_BYTES + 64);
|
||||||
|
resource.extend_from_slice(&BITMAP_HEADER_SIZE.to_le_bytes());
|
||||||
|
resource.extend_from_slice(&(WIDTH as i32).to_le_bytes());
|
||||||
|
resource.extend_from_slice(&((HEIGHT * 2) as i32).to_le_bytes());
|
||||||
|
resource.extend_from_slice(&1u16.to_le_bytes());
|
||||||
|
resource.extend_from_slice(&32u16.to_le_bytes());
|
||||||
|
resource.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
resource.extend_from_slice(&(XOR_BYTES as u32).to_le_bytes());
|
||||||
|
resource.extend_from_slice(&0i32.to_le_bytes());
|
||||||
|
resource.extend_from_slice(&0i32.to_le_bytes());
|
||||||
|
resource.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
resource.extend_from_slice(&0u32.to_le_bytes());
|
||||||
|
|
||||||
|
for y in (0..HEIGHT).rev() {
|
||||||
|
for x in 0..WIDTH {
|
||||||
|
if windows_badge_pixel_is_red(x, y) {
|
||||||
|
resource.extend_from_slice(&[42, 48, 239, 255]);
|
||||||
|
} else {
|
||||||
|
resource.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for y in (0..HEIGHT).rev() {
|
||||||
|
let mut row = [0u8; AND_ROW_BYTES];
|
||||||
|
for x in 0..WIDTH {
|
||||||
|
if !windows_badge_pixel_is_red(x, y) {
|
||||||
|
row[x / 8] |= 0x80 >> (x % 8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resource.extend_from_slice(&row);
|
||||||
|
}
|
||||||
|
resource
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn windows_badge_pixel_is_red(x: usize, y: usize) -> bool {
|
||||||
|
let dx = x as f32 - 7.5;
|
||||||
|
let dy = y as f32 - 7.5;
|
||||||
|
dx * dx + dy * dy <= 42.25
|
||||||
|
}
|
||||||
+30
-6
@@ -1,34 +1,46 @@
|
|||||||
#![windows_subsystem = "windows"]
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
use gpui::{ KeyBinding };
|
use gpui::KeyBinding;
|
||||||
use gpui_component_assets::Assets;
|
use gpui_component_assets::Assets;
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod backend;
|
mod backend;
|
||||||
|
mod desktop_notification;
|
||||||
mod session;
|
mod session;
|
||||||
mod sftp;
|
mod sftp;
|
||||||
|
mod sync;
|
||||||
mod system;
|
mod system;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
|
mod text_encoding;
|
||||||
|
|
||||||
rust_i18n::i18n!("locales", fallback = "en");
|
rust_i18n::i18n!("locales", fallback = "en");
|
||||||
|
|
||||||
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
|
gpui::actions!(ashell_terminal, [TerminalTabKey, TerminalBacktabKey]);
|
||||||
|
|
||||||
pub(crate) use app::{
|
pub(crate) use app::keybinding_recorder::{
|
||||||
Ashell, ConnectionProgress, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup,
|
ClosePane, Copy, FocusPaneDown, FocusPaneLeft, FocusPaneRight, FocusPaneUp, NewSsh, OpenSearch,
|
||||||
|
OpenSession, OpenSettings, OpenTransfers, Paste, QuitApplication, SplitPaneDown, SplitPaneLeft,
|
||||||
|
SplitPaneRight, SplitPaneUp, ToggleSftpZoom, ToggleSidebar,
|
||||||
};
|
};
|
||||||
|
pub(crate) use app::system_menu::{
|
||||||
|
AboutAshell, CloseWindow, MinimizeWindow, NewLocalTerminal, ToggleFullScreen, ZoomWindow,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) use app::{Ashell, PaneLayout, SelectorEntry, SftpContextMenuState, TabGroup};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
app::startup::sync_macos_launch_environment();
|
app::startup::sync_macos_launch_environment();
|
||||||
app::startup::init_logging();
|
app::startup::init_logging();
|
||||||
|
desktop_notification::initialize();
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let app = gpui_platform::application()
|
let app = gpui_platform::application()
|
||||||
.with_assets(Assets)
|
.with_assets(Assets)
|
||||||
.with_quit_mode(gpui::QuitMode::Explicit);
|
.with_quit_mode(gpui::QuitMode::LastWindowClosed);
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
let app = gpui_platform::application().with_assets(Assets);
|
let app = gpui_platform::application().with_assets(Assets);
|
||||||
|
|
||||||
app.on_reopen(|cx| {
|
app.on_reopen(|cx| {
|
||||||
if cx.windows().is_empty() {
|
if cx.windows().is_empty() {
|
||||||
app::startup::open_main_window(cx);
|
app::startup::open_main_window(cx);
|
||||||
@@ -36,10 +48,22 @@ fn main() {
|
|||||||
});
|
});
|
||||||
app.run(move |cx| {
|
app.run(move |cx| {
|
||||||
gpui_component::init(cx);
|
gpui_component::init(cx);
|
||||||
|
app::system_menu::init(cx);
|
||||||
|
cx.on_action(|_: &QuitApplication, cx| cx.quit());
|
||||||
|
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new("tab", TerminalTabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
|
KeyBinding::new(
|
||||||
KeyBinding::new("shift-tab", TerminalBacktabKey, Some(app::constants::TERMINAL_KEY_CONTEXT)),
|
"tab",
|
||||||
|
TerminalTabKey,
|
||||||
|
Some(app::constants::TERMINAL_KEY_CONTEXT),
|
||||||
|
),
|
||||||
|
KeyBinding::new(
|
||||||
|
"shift-tab",
|
||||||
|
TerminalBacktabKey,
|
||||||
|
Some(app::constants::TERMINAL_KEY_CONTEXT),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
app::startup::bind_workspace_keys(cx);
|
||||||
app::theme::load_embedded_themes(cx);
|
app::theme::load_embedded_themes(cx);
|
||||||
if let Err(err) = app::theme::load_fonts(cx) {
|
if let Err(err) = app::theme::load_fonts(cx) {
|
||||||
tracing::warn!("failed to load embedded fonts: {err:#}");
|
tracing::warn!("failed to load embedded fonts: {err:#}");
|
||||||
|
|||||||
+1809
-29
File diff suppressed because it is too large
Load Diff
+2040
-371
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)
|
||||||
|
}
|
||||||
+641
-392
File diff suppressed because it is too large
Load Diff
+228
-40
@@ -6,57 +6,177 @@ use crate::{
|
|||||||
terminal,
|
terminal,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) fn is_editable_text_file(filename: &str) -> bool {
|
|
||||||
let lower = filename.to_lowercase();
|
|
||||||
let ext = std::path::Path::new(&lower).extension().and_then(|s| s.to_str()).unwrap_or("");
|
|
||||||
let known_exts = ["txt", "conf", "json", "yaml", "yml", "xml", "ini", "sh", "py", "rs", "js", "ts", "html", "css", "md", "toml", "csv", "log", "cfg"];
|
|
||||||
if known_exts.contains(&ext) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
let known_names = ["dockerfile", "makefile", ".gitignore", ".env"];
|
|
||||||
if known_names.contains(&lower.as_str()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ashell {
|
impl Ashell {
|
||||||
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
pub(crate) fn active_sftp(&self) -> Option<&terminal::SftpUiState> {
|
||||||
self.active_group.as_ref()
|
self.active_group
|
||||||
|
.as_ref()
|
||||||
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
|
.and_then(|id| self.tab_groups.iter().find(|g| &g.id == id))
|
||||||
.and_then(|g| g.sftp.as_ref())
|
.and_then(|g| g.sftp.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
|
pub(crate) fn active_sftp_mut(&mut self) -> Option<&mut terminal::SftpUiState> {
|
||||||
let active_id = self.active_group.clone()?;
|
let active_id = self.active_group.clone()?;
|
||||||
self.tab_groups.iter_mut().find(|g| g.id == active_id)
|
self.tab_groups
|
||||||
|
.iter_mut()
|
||||||
|
.find(|g| g.id == active_id)
|
||||||
.and_then(|g| g.sftp.as_mut())
|
.and_then(|g| g.sftp.as_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
|
pub(crate) fn active_sftp_handle(&self) -> Option<&SftpHandle> {
|
||||||
self.active_group.as_ref()
|
self.active_group
|
||||||
|
.as_ref()
|
||||||
.and_then(|id| self.sftp_handles.get(id))
|
.and_then(|id| self.sftp_handles.get(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
|
pub(crate) fn navigate_sftp(&mut self, path: String, cx: &mut Context<Self>) {
|
||||||
if let Some(handle) = self.active_sftp_handle() {
|
let Some((current_path, home_dir)) = self
|
||||||
tracing::info!("[sftp] navigating to directory: '{}'", path);
|
.active_sftp()
|
||||||
handle.list_dir(path.clone());
|
.map(|sftp| (sftp.current_path.clone(), sftp.home_dir.clone()))
|
||||||
if let Some(sftp) = self.active_sftp_mut() {
|
else {
|
||||||
sftp.current_path = path;
|
return;
|
||||||
self.pending_sftp_path_sync = Some(sftp.current_path.clone());
|
};
|
||||||
|
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();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn select_sftp_entry(&mut self, entry: RemoteEntry, cx: &mut Context<Self>) {
|
pub(crate) fn select_sftp_entry(
|
||||||
|
&mut self,
|
||||||
|
entry: RemoteEntry,
|
||||||
|
visible_paths: &[String],
|
||||||
|
extend_range: bool,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
if extend_range {
|
||||||
|
if let Some(sftp) = self.active_sftp_mut() {
|
||||||
|
let target_path = entry.full_path.clone();
|
||||||
|
let target_index = visible_paths.iter().position(|path| path == &target_path);
|
||||||
|
let anchor_index = sftp
|
||||||
|
.selection_anchor
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|anchor| visible_paths.iter().position(|path| path == anchor));
|
||||||
|
|
||||||
|
sftp.selected_entries.clear();
|
||||||
|
if let Some(target_index) = target_index {
|
||||||
|
let anchor_index = anchor_index.unwrap_or(target_index);
|
||||||
|
let start = anchor_index.min(target_index);
|
||||||
|
let end = anchor_index.max(target_index);
|
||||||
|
sftp.selected_entries
|
||||||
|
.extend(visible_paths[start..=end].iter().cloned());
|
||||||
|
} else {
|
||||||
|
sftp.selected_entries.insert(target_path.clone());
|
||||||
|
}
|
||||||
|
if sftp.selection_anchor.is_none() {
|
||||||
|
sftp.selection_anchor = Some(target_path.clone());
|
||||||
|
}
|
||||||
|
sftp.selected_path = Some(target_path);
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if entry.is_dir {
|
if entry.is_dir {
|
||||||
self.navigate_sftp(entry.full_path, cx);
|
self.navigate_sftp(entry.full_path, cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.mark_sftp_entry_selected(&entry.full_path, cx);
|
self.mark_sftp_entry_selected(&entry.full_path, cx);
|
||||||
if let Some(sftp) = self.active_sftp_mut() {
|
if let Some(sftp) = self.active_sftp_mut() {
|
||||||
|
sftp.selection_anchor = Some(entry.full_path.clone());
|
||||||
if !sftp.selected_entries.remove(&entry.full_path) {
|
if !sftp.selected_entries.remove(&entry.full_path) {
|
||||||
sftp.selected_entries.insert(entry.full_path);
|
sftp.selected_entries.insert(entry.full_path);
|
||||||
}
|
}
|
||||||
@@ -122,7 +242,11 @@ impl Ashell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn trigger_sftp_context_download(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
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 {
|
let Some(menu) = self.sftp_context_menu.take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -130,15 +254,27 @@ impl Ashell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn trigger_sftp_context_edit(&mut self, cx: &mut Context<Self>) {
|
pub(crate) fn trigger_sftp_context_edit(
|
||||||
|
&mut self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let Some(menu) = self.sftp_context_menu.take() else {
|
let Some(menu) = self.sftp_context_menu.take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(handle) = self.active_sftp_handle() {
|
tracing::info!("[sftp] opening in-app editor for: '{}'", menu.remote_path);
|
||||||
tracing::info!("[sftp] triggering edit for file: '{}'", menu.remote_path);
|
self.show_sftp_editor_dialog(menu.remote_path, window, cx);
|
||||||
handle.edit_file(menu.remote_path);
|
}
|
||||||
}
|
|
||||||
cx.notify();
|
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(
|
pub(crate) fn download_sftp_entry(
|
||||||
@@ -161,8 +297,16 @@ impl Ashell {
|
|||||||
Ok(Ok(Some(mut paths))) => {
|
Ok(Ok(Some(mut paths))) => {
|
||||||
if let Some(folder) = paths.pop() {
|
if let Some(folder) = paths.pop() {
|
||||||
let local_path = folder.to_string_lossy().to_string();
|
let local_path = folder.to_string_lossy().to_string();
|
||||||
tracing::info!("[sftp] initiating download of '{}' to '{}'", remote_path, local_path);
|
tracing::info!(
|
||||||
|
"[sftp] initiating download of '{}' to '{}'",
|
||||||
|
remote_path,
|
||||||
|
local_path
|
||||||
|
);
|
||||||
handle.download(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)) => {
|
Ok(Err(err)) => {
|
||||||
@@ -197,8 +341,16 @@ impl Ashell {
|
|||||||
Ok(Ok(Some(mut paths))) => {
|
Ok(Ok(Some(mut paths))) => {
|
||||||
if let Some(file) = paths.pop() {
|
if let Some(file) = paths.pop() {
|
||||||
let local_path = file.to_string_lossy().to_string();
|
let local_path = file.to_string_lossy().to_string();
|
||||||
tracing::info!("[sftp] initiating upload of file '{}' to '{}'", local_path, remote_dir);
|
tracing::info!(
|
||||||
|
"[sftp] initiating upload of file '{}' to '{}'",
|
||||||
|
local_path,
|
||||||
|
remote_dir
|
||||||
|
);
|
||||||
handle.upload_paths(vec![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)) => {
|
Ok(Err(err)) => {
|
||||||
@@ -233,8 +385,16 @@ impl Ashell {
|
|||||||
Ok(Ok(Some(mut paths))) => {
|
Ok(Ok(Some(mut paths))) => {
|
||||||
if let Some(folder) = paths.pop() {
|
if let Some(folder) = paths.pop() {
|
||||||
let local_path = folder.to_string_lossy().to_string();
|
let local_path = folder.to_string_lossy().to_string();
|
||||||
tracing::info!("[sftp] initiating upload of folder '{}' to '{}'", local_path, remote_dir);
|
tracing::info!(
|
||||||
|
"[sftp] initiating upload of folder '{}' to '{}'",
|
||||||
|
local_path,
|
||||||
|
remote_dir
|
||||||
|
);
|
||||||
handle.upload_paths(vec![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)) => {
|
Ok(Err(err)) => {
|
||||||
@@ -250,8 +410,15 @@ impl Ashell {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn toggle_sftp_entry(&mut self, path: String, checked: bool, cx: &mut Context<Self>) {
|
pub(crate) fn toggle_sftp_entry(
|
||||||
|
&mut self,
|
||||||
|
path: String,
|
||||||
|
checked: bool,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
if let Some(sftp) = self.active_sftp_mut() {
|
if let Some(sftp) = self.active_sftp_mut() {
|
||||||
|
sftp.selected_path = Some(path.clone());
|
||||||
|
sftp.selection_anchor = Some(path.clone());
|
||||||
if checked {
|
if checked {
|
||||||
sftp.selected_entries.insert(path);
|
sftp.selected_entries.insert(path);
|
||||||
} else {
|
} else {
|
||||||
@@ -264,18 +431,28 @@ impl Ashell {
|
|||||||
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
|
pub(crate) fn toggle_all_sftp_entries(&mut self, checked: bool, cx: &mut Context<Self>) {
|
||||||
if let Some(sftp) = self.active_sftp_mut() {
|
if let Some(sftp) = self.active_sftp_mut() {
|
||||||
if checked {
|
if checked {
|
||||||
let paths: Vec<String> = sftp.entries.iter().map(|e| e.full_path.clone()).collect();
|
let paths: Vec<String> = sftp
|
||||||
|
.current_entries()
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.full_path.clone())
|
||||||
|
.collect();
|
||||||
for path in paths {
|
for path in paths {
|
||||||
sftp.selected_entries.insert(path);
|
sftp.selected_entries.insert(path);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sftp.selected_entries.clear();
|
sftp.selected_entries.clear();
|
||||||
|
sftp.selected_path = None;
|
||||||
|
sftp.selection_anchor = None;
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn download_selected_sftp_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub(crate) fn download_selected_sftp_entries(
|
||||||
|
&mut self,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
let Some(sftp) = self.active_sftp() else {
|
let Some(sftp) = self.active_sftp() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -299,7 +476,11 @@ impl Ashell {
|
|||||||
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
|
if let Ok(Ok(Some(mut paths))) = path_prompt.await {
|
||||||
if let Some(folder) = paths.pop() {
|
if let Some(folder) = paths.pop() {
|
||||||
let local_dir = folder.to_string_lossy().to_string();
|
let local_dir = folder.to_string_lossy().to_string();
|
||||||
tracing::info!("[sftp] initiating batch download of {} entries to '{}'", selected.len(), local_dir);
|
tracing::info!(
|
||||||
|
"[sftp] initiating batch download of {} entries to '{}'",
|
||||||
|
selected.len(),
|
||||||
|
local_dir
|
||||||
|
);
|
||||||
for remote in selected {
|
for remote in selected {
|
||||||
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
|
let _ = handle.commands.send(crate::sftp::SftpCommand::Download {
|
||||||
remote,
|
remote,
|
||||||
@@ -311,6 +492,7 @@ impl Ashell {
|
|||||||
if let Some(sftp_mut) = this.active_sftp_mut() {
|
if let Some(sftp_mut) = this.active_sftp_mut() {
|
||||||
sftp_mut.selected_entries.clear();
|
sftp_mut.selected_entries.clear();
|
||||||
}
|
}
|
||||||
|
this.show_transfers_dialog = true;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -320,17 +502,23 @@ impl Ashell {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, _cx: &mut Context<Self>) {
|
pub(crate) fn upload_sftp_files_batch(&mut self, paths: Vec<String>, cx: &mut Context<Self>) {
|
||||||
if paths.is_empty() {
|
if paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(sftp) = self.active_sftp() {
|
if let Some(sftp) = self.active_sftp() {
|
||||||
if let Some(handle) = self.active_sftp_handle() {
|
if let Some(handle) = self.active_sftp_handle() {
|
||||||
tracing::info!("[sftp] initiating batch upload of {} files to '{}'", paths.len(), sftp.current_path);
|
tracing::info!(
|
||||||
|
"[sftp] initiating batch upload of {} files to '{}'",
|
||||||
|
paths.len(),
|
||||||
|
sftp.current_path
|
||||||
|
);
|
||||||
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
|
let _ = handle.commands.send(crate::sftp::SftpCommand::UploadPaths {
|
||||||
locals: paths,
|
locals: paths,
|
||||||
remote_dir: sftp.current_path.clone(),
|
remote_dir: sftp.current_path.clone(),
|
||||||
});
|
});
|
||||||
|
self.show_transfers_dialog = true;
|
||||||
|
cx.notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
-33
@@ -22,6 +22,25 @@ pub struct DiskSample {
|
|||||||
pub total_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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct SystemSnapshot {
|
pub struct SystemSnapshot {
|
||||||
pub cpu_percent: f32,
|
pub cpu_percent: f32,
|
||||||
@@ -52,8 +71,8 @@ impl SystemSampler {
|
|||||||
sys.refresh_all();
|
sys.refresh_all();
|
||||||
let nets = Networks::new_with_refreshed_list();
|
let nets = Networks::new_with_refreshed_list();
|
||||||
let disks = Disks::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_rx_total = nets.values().map(|d| d.total_received()).sum();
|
||||||
let last_tx_total = nets.iter().map(|(_, d)| d.total_transmitted()).sum();
|
let last_tx_total = nets.values().map(|d| d.total_transmitted()).sum();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
sys,
|
sys,
|
||||||
@@ -81,8 +100,8 @@ impl SystemSampler {
|
|||||||
let swap_total = self.sys.total_swap();
|
let swap_total = self.sys.total_swap();
|
||||||
let swap_used = self.sys.used_swap();
|
let swap_used = self.sys.used_swap();
|
||||||
|
|
||||||
let rx_total: u64 = self.nets.iter().map(|(_, d)| d.total_received()).sum();
|
let rx_total: u64 = self.nets.values().map(|d| d.total_received()).sum();
|
||||||
let tx_total: u64 = self.nets.iter().map(|(_, d)| d.total_transmitted()).sum();
|
let tx_total: u64 = self.nets.values().map(|d| d.total_transmitted()).sum();
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let elapsed = now
|
let elapsed = now
|
||||||
.duration_since(self.last_instant)
|
.duration_since(self.last_instant)
|
||||||
@@ -104,24 +123,16 @@ impl SystemSampler {
|
|||||||
total_bytes: disk.total_space(),
|
total_bytes: disk.total_space(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
disks.sort_by(|a, b| {
|
sort_disks(&mut disks);
|
||||||
if a.mount == "/" {
|
|
||||||
return std::cmp::Ordering::Less;
|
|
||||||
}
|
|
||||||
if b.mount == "/" {
|
|
||||||
return std::cmp::Ordering::Greater;
|
|
||||||
}
|
|
||||||
a.mount.cmp(&b.mount)
|
|
||||||
});
|
|
||||||
|
|
||||||
SystemSnapshot {
|
SystemSnapshot {
|
||||||
cpu_percent,
|
cpu_percent,
|
||||||
mem_percent: ratio(mem_used, mem_total),
|
mem_percent: ratio(mem_used, mem_total),
|
||||||
swap_percent: ratio(swap_used, swap_total),
|
swap_percent: ratio(swap_used, swap_total),
|
||||||
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
|
mem_detail: format_usage(mem_used, mem_total),
|
||||||
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
|
swap_detail: format_usage(swap_used, swap_total),
|
||||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
net_rx: format_rate(rx_rate),
|
||||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
net_tx: format_rate(tx_rate),
|
||||||
net_rx_rate: rx_rate,
|
net_rx_rate: rx_rate,
|
||||||
net_tx_rate: tx_rate,
|
net_tx_rate: tx_rate,
|
||||||
disks,
|
disks,
|
||||||
@@ -153,6 +164,22 @@ pub fn format_bytes(bytes: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_usage(used: u64, total: u64) -> String {
|
||||||
|
format!("{}/{}", format_bytes(used), format_bytes(total))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_rate(bytes: u64) -> String {
|
||||||
|
format!("{}/s", format_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_disks(disks: &mut [DiskSample]) {
|
||||||
|
disks.sort_by(|a, b| {
|
||||||
|
(a.mount != "/")
|
||||||
|
.cmp(&(b.mount != "/"))
|
||||||
|
.then_with(|| a.mount.cmp(&b.mount))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
||||||
let mut kv = BTreeMap::new();
|
let mut kv = BTreeMap::new();
|
||||||
let mut disks = Vec::new();
|
let mut disks = Vec::new();
|
||||||
@@ -173,7 +200,7 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
disks.push(DiskSample {
|
disks.push(DiskSample {
|
||||||
mount,
|
mount,
|
||||||
available_bytes,
|
available_bytes: available_bytes.min(total_bytes),
|
||||||
total_bytes,
|
total_bytes,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -189,13 +216,15 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
|||||||
.get("CPU_PERCENT")
|
.get("CPU_PERCENT")
|
||||||
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
|
.ok_or_else(|| anyhow!("missing CPU_PERCENT"))?
|
||||||
.parse::<f32>()
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_finite())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
/ 100.0;
|
/ 100.0;
|
||||||
|
|
||||||
let mem_used = parse_u64(&kv, "MEM_USED");
|
|
||||||
let mem_total = parse_u64(&kv, "MEM_TOTAL");
|
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 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 rx_rate = parse_u64(&kv, "NET_RX");
|
||||||
let tx_rate = parse_u64(&kv, "NET_TX");
|
let tx_rate = parse_u64(&kv, "NET_TX");
|
||||||
|
|
||||||
@@ -203,24 +232,16 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
|||||||
// (catches any virtual fs lines that slipped past the script filter)
|
// (catches any virtual fs lines that slipped past the script filter)
|
||||||
disks.retain(|d| d.total_bytes >= 1024 * 1024);
|
disks.retain(|d| d.total_bytes >= 1024 * 1024);
|
||||||
|
|
||||||
disks.sort_by(|a, b| {
|
sort_disks(&mut disks);
|
||||||
if a.mount == "/" {
|
|
||||||
return std::cmp::Ordering::Less;
|
|
||||||
}
|
|
||||||
if b.mount == "/" {
|
|
||||||
return std::cmp::Ordering::Greater;
|
|
||||||
}
|
|
||||||
a.mount.cmp(&b.mount)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(SystemSnapshot {
|
Ok(SystemSnapshot {
|
||||||
cpu_percent: cpu_percent.clamp(0.0, 1.0),
|
cpu_percent: cpu_percent.clamp(0.0, 1.0),
|
||||||
mem_percent: ratio(mem_used, mem_total),
|
mem_percent: ratio(mem_used, mem_total),
|
||||||
swap_percent: ratio(swap_used, swap_total),
|
swap_percent: ratio(swap_used, swap_total),
|
||||||
mem_detail: format!("{}/{}", format_bytes(mem_used), format_bytes(mem_total)),
|
mem_detail: format_usage(mem_used, mem_total),
|
||||||
swap_detail: format!("{}/{}", format_bytes(swap_used), format_bytes(swap_total)),
|
swap_detail: format_usage(swap_used, swap_total),
|
||||||
net_rx: format!("{}/s", format_bytes(rx_rate)),
|
net_rx: format_rate(rx_rate),
|
||||||
net_tx: format!("{}/s", format_bytes(tx_rate)),
|
net_tx: format_rate(tx_rate),
|
||||||
net_rx_rate: rx_rate,
|
net_rx_rate: rx_rate,
|
||||||
net_tx_rate: tx_rate,
|
net_tx_rate: tx_rate,
|
||||||
disks,
|
disks,
|
||||||
@@ -228,8 +249,168 @@ pub fn remote_snapshot_from_kv(raw: &str) -> Result<SystemSnapshot> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn remote_processes_from_ps(raw: &str) -> Vec<RemoteProcess> {
|
||||||
|
raw.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
if let Some(record) = line.strip_prefix("PROCESS\t") {
|
||||||
|
let mut fields = record.splitn(5, '\t');
|
||||||
|
let pid = fields.next()?.parse::<u32>().ok()?;
|
||||||
|
let user = fields.next()?.to_string();
|
||||||
|
let cpu_percent = fields
|
||||||
|
.next()?
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_finite())
|
||||||
|
.map(|value| value.max(0.0))?;
|
||||||
|
let memory_bytes = fields.next()?.parse::<u64>().ok()?;
|
||||||
|
let command = fields.next()?.trim().to_string();
|
||||||
|
return (!command.is_empty()).then_some(RemoteProcess {
|
||||||
|
pid,
|
||||||
|
user,
|
||||||
|
cpu_percent,
|
||||||
|
memory_bytes,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fields = line.split_whitespace();
|
||||||
|
let pid = fields.next()?.parse::<u32>().ok()?;
|
||||||
|
let user = fields.next()?.to_string();
|
||||||
|
let cpu_percent = fields
|
||||||
|
.next()?
|
||||||
|
.parse::<f32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|value| value.is_finite())
|
||||||
|
.map(|value| value.max(0.0))?;
|
||||||
|
let memory_bytes = fields.next()?.parse::<u64>().ok()?.saturating_mul(1024);
|
||||||
|
let command = fields.collect::<Vec<_>>().join(" ");
|
||||||
|
if command.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(RemoteProcess {
|
||||||
|
pid,
|
||||||
|
user,
|
||||||
|
cpu_percent,
|
||||||
|
memory_bytes,
|
||||||
|
command,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote_ports_from_probe(raw: &str) -> Vec<RemotePort> {
|
||||||
|
raw.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let record = line.strip_prefix("PORT\t")?;
|
||||||
|
let mut fields = record.splitn(6, '\t');
|
||||||
|
let protocol = fields.next()?.trim().to_string();
|
||||||
|
let address = fields.next()?.trim().to_string();
|
||||||
|
let port = fields.next()?.parse::<u16>().ok()?;
|
||||||
|
let state = fields.next()?.trim().to_string();
|
||||||
|
let pid = match fields.next()?.trim() {
|
||||||
|
"" | "-" | "0" => None,
|
||||||
|
value => value.parse::<u32>().ok(),
|
||||||
|
};
|
||||||
|
let process = fields.next()?.trim().to_string();
|
||||||
|
if protocol.is_empty() || address.is_empty() || state.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(RemotePort {
|
||||||
|
protocol,
|
||||||
|
address,
|
||||||
|
port,
|
||||||
|
state,
|
||||||
|
pid,
|
||||||
|
process: if process.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
process
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
|
fn parse_u64(kv: &BTreeMap<String, String>, key: &str) -> u64 {
|
||||||
kv.get(key)
|
kv.get(key)
|
||||||
.and_then(|value| value.parse::<u64>().ok())
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
.unwrap_or_default()
|
.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+400
-56
@@ -11,6 +11,8 @@ use gpui::{
|
|||||||
use gpui_component::ActiveTheme as _;
|
use gpui_component::ActiveTheme as _;
|
||||||
|
|
||||||
use crate::Ashell;
|
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};
|
use crate::terminal::{RenderSnapshot, ViewportSelection};
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -38,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)]
|
#[derive(Clone)]
|
||||||
struct BatchedTextRun {
|
struct BatchedTextRun {
|
||||||
row: i32,
|
row: i32,
|
||||||
@@ -125,12 +147,29 @@ struct CursorLayout {
|
|||||||
pub struct TerminalElement {
|
pub struct TerminalElement {
|
||||||
view: Entity<Ashell>,
|
view: Entity<Ashell>,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
|
pane_focused: bool,
|
||||||
snapshot: RenderSnapshot,
|
snapshot: RenderSnapshot,
|
||||||
marked_text: Option<String>,
|
marked_text: Option<String>,
|
||||||
font_family: SharedString,
|
font_family: SharedString,
|
||||||
font_size: Pixels,
|
font_size: Pixels,
|
||||||
line_height: Pixels,
|
line_height: Pixels,
|
||||||
cell_width: 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 {
|
pub struct PrepaintState {
|
||||||
@@ -138,7 +177,18 @@ pub struct PrepaintState {
|
|||||||
metrics: TerminalMetrics,
|
metrics: TerminalMetrics,
|
||||||
rects: Vec<LayoutRect>,
|
rects: Vec<LayoutRect>,
|
||||||
runs: Vec<BatchedTextRun>,
|
runs: Vec<BatchedTextRun>,
|
||||||
|
custom_blocks: Vec<LayoutCustomBlock>,
|
||||||
cursor: Option<CursorLayout>,
|
cursor: Option<CursorLayout>,
|
||||||
|
underlines: Vec<LayoutUnderline>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct LayoutCustomBlock {
|
||||||
|
c: char,
|
||||||
|
row: i32,
|
||||||
|
col: i32,
|
||||||
|
cells: usize,
|
||||||
|
color: Hsla,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TerminalInputHandler {
|
struct TerminalInputHandler {
|
||||||
@@ -219,9 +269,12 @@ impl InputHandler for TerminalInputHandler {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Option<Bounds<Pixels>> {
|
) -> Option<Bounds<Pixels>> {
|
||||||
self.view
|
self.view.read(cx).terminal_ime_bounds_for_range(
|
||||||
.read(cx)
|
range_utf16,
|
||||||
.terminal_ime_bounds_for_range(range_utf16, self.element_bounds, self.cell_width, self.line_height)
|
self.element_bounds,
|
||||||
|
self.cell_width,
|
||||||
|
self.line_height,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn character_index_for_point(
|
fn character_index_for_point(
|
||||||
@@ -247,25 +300,32 @@ impl InputHandler for TerminalInputHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TerminalElement {
|
impl TerminalElement {
|
||||||
pub fn new(
|
pub(crate) fn new(config: TerminalElementConfig) -> Self {
|
||||||
view: Entity<Ashell>,
|
let TerminalElementConfig {
|
||||||
focus_handle: FocusHandle,
|
|
||||||
snapshot: RenderSnapshot,
|
|
||||||
marked_text: Option<String>,
|
|
||||||
font_family: SharedString,
|
|
||||||
font_size: Pixels,
|
|
||||||
line_height: Pixels,
|
|
||||||
cell_width: Pixels,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
view,
|
view,
|
||||||
focus_handle,
|
focus_handle,
|
||||||
|
pane_focused,
|
||||||
snapshot,
|
snapshot,
|
||||||
marked_text,
|
marked_text,
|
||||||
font_family,
|
font_family,
|
||||||
font_size,
|
font_size,
|
||||||
line_height,
|
line_height,
|
||||||
cell_width,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,21 +339,16 @@ impl TerminalElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cell_run_style(&self, cell: &alacritty_terminal::term::cell::Cell, cx: &App) -> TextRun {
|
fn cell_run_style(
|
||||||
let mut fg = color_to_hsla(cell.fg, true, cx);
|
&self,
|
||||||
let mut bg = color_to_hsla(cell.bg, false, cx);
|
cell: &alacritty_terminal::term::cell::Cell,
|
||||||
if cell.flags.contains(Flags::INVERSE) {
|
foreground: Hsla,
|
||||||
std::mem::swap(&mut fg, &mut bg);
|
) -> TextRun {
|
||||||
}
|
|
||||||
if cell.flags.contains(Flags::DIM) {
|
|
||||||
fg.a *= 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
let underline = cell
|
let underline = cell
|
||||||
.flags
|
.flags
|
||||||
.intersects(Flags::ALL_UNDERLINES)
|
.intersects(Flags::ALL_UNDERLINES)
|
||||||
.then(|| UnderlineStyle {
|
.then(|| UnderlineStyle {
|
||||||
color: Some(fg),
|
color: Some(foreground),
|
||||||
thickness: px(1.0),
|
thickness: px(1.0),
|
||||||
wavy: cell.flags.contains(Flags::UNDERCURL),
|
wavy: cell.flags.contains(Flags::UNDERCURL),
|
||||||
});
|
});
|
||||||
@@ -301,7 +356,7 @@ impl TerminalElement {
|
|||||||
.flags
|
.flags
|
||||||
.contains(Flags::STRIKEOUT)
|
.contains(Flags::STRIKEOUT)
|
||||||
.then(|| StrikethroughStyle {
|
.then(|| StrikethroughStyle {
|
||||||
color: Some(fg),
|
color: Some(foreground),
|
||||||
thickness: px(1.0),
|
thickness: px(1.0),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,7 +373,7 @@ impl TerminalElement {
|
|||||||
|
|
||||||
TextRun {
|
TextRun {
|
||||||
len: cell.c.len_utf8(),
|
len: cell.c.len_utf8(),
|
||||||
color: fg,
|
color: foreground,
|
||||||
background_color: None,
|
background_color: None,
|
||||||
font: Font {
|
font: Font {
|
||||||
family: self.font_family.clone(),
|
family: self.font_family.clone(),
|
||||||
@@ -331,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 rects = Vec::new();
|
||||||
let mut runs = 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;
|
let mut current_run: Option<BatchedTextRun> = None;
|
||||||
|
|
||||||
for render_cell in &self.snapshot.cells {
|
for render_cell in &self.snapshot.cells {
|
||||||
@@ -347,20 +415,54 @@ impl TerminalElement {
|
|||||||
let selected = self.snapshot.selection.is_some_and(|selection| {
|
let selected = self.snapshot.selection.is_some_and(|selection| {
|
||||||
selection_contains(selection, render_cell.row, render_cell.col)
|
selection_contains(selection, render_cell.row, render_cell.col)
|
||||||
});
|
});
|
||||||
let bg = color_to_hsla(cell.bg, false, cx);
|
let cell_position = (render_cell.row, render_cell.col);
|
||||||
if selected || !is_default_bg(cell.bg) {
|
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 {
|
rects.push(LayoutRect {
|
||||||
row: render_cell.row,
|
row: render_cell.row,
|
||||||
col: render_cell.col,
|
col: render_cell.col,
|
||||||
cells: 1,
|
cells: cell_span,
|
||||||
color: if selected {
|
color: cx.theme().selection,
|
||||||
cx.theme().selection
|
|
||||||
} else if cell.flags.contains(Flags::INVERSE) {
|
|
||||||
color_to_hsla(cell.fg, true, cx)
|
|
||||||
} else {
|
|
||||||
bg
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
} 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) {
|
if is_blank(cell) {
|
||||||
@@ -370,7 +472,49 @@ impl TerminalElement {
|
|||||||
continue;
|
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 let Some(run) = current_run.as_mut() {
|
||||||
if run.can_append(&style, render_cell.row, render_cell.col) {
|
if run.can_append(&style, render_cell.row, render_cell.col) {
|
||||||
run.append(cell.c, cell.zerowidth());
|
run.append(cell.c, cell.zerowidth());
|
||||||
@@ -402,19 +546,62 @@ impl TerminalElement {
|
|||||||
runs.push(run);
|
runs.push(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
(merge_rects(rects), runs)
|
(
|
||||||
|
merge_rects(rects),
|
||||||
|
runs,
|
||||||
|
custom_blocks,
|
||||||
|
merge_underlines(underlines),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cursor_layout(&self, cx: &App) -> Option<CursorLayout> {
|
fn cursor_layout(&self, window: &Window, cx: &App) -> Option<CursorLayout> {
|
||||||
self.snapshot.cursor.map(|cursor| CursorLayout {
|
if !should_render_cursor(
|
||||||
row: cursor.row,
|
window.is_window_active(),
|
||||||
col: cursor.col,
|
self.focus_handle.is_focused(window),
|
||||||
shape: cursor.shape,
|
self.pane_focused,
|
||||||
color: cx.theme().primary,
|
) {
|
||||||
|
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 {
|
impl IntoElement for TerminalElement {
|
||||||
type Element = Self;
|
type Element = Self;
|
||||||
|
|
||||||
@@ -454,11 +641,31 @@ impl Element for TerminalElement {
|
|||||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||||
bounds: Bounds<Pixels>,
|
bounds: Bounds<Pixels>,
|
||||||
_request_layout: &mut Self::RequestLayoutState,
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
_window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Self::PrepaintState {
|
) -> Self::PrepaintState {
|
||||||
let _ = self.base_text_style(cx);
|
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 {
|
PrepaintState {
|
||||||
bounds,
|
bounds,
|
||||||
metrics: TerminalMetrics {
|
metrics: TerminalMetrics {
|
||||||
@@ -467,7 +674,9 @@ impl Element for TerminalElement {
|
|||||||
},
|
},
|
||||||
rects,
|
rects,
|
||||||
runs,
|
runs,
|
||||||
cursor: self.cursor_layout(cx),
|
custom_blocks,
|
||||||
|
cursor: self.cursor_layout(window, cx),
|
||||||
|
underlines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,19 +690,50 @@ impl Element for TerminalElement {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
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 {
|
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 {
|
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(
|
window.handle_input(
|
||||||
&self.focus_handle,
|
&self.focus_handle,
|
||||||
TerminalInputHandler {
|
TerminalInputHandler {
|
||||||
view: self.view.clone(),
|
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(),
|
cell_width: prepaint.metrics.cell_width.as_f32(),
|
||||||
line_height: prepaint.metrics.line_height.as_f32(),
|
line_height: prepaint.metrics.line_height.as_f32(),
|
||||||
},
|
},
|
||||||
@@ -503,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(marked_text) = self.marked_text.as_ref().filter(|text| !text.is_empty()) {
|
||||||
if let Some(cursor) = prepaint.cursor {
|
if let Some(cursor) = prepaint.cursor {
|
||||||
let pos = point(
|
let pos = point(
|
||||||
prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32,
|
draw_origin.x + prepaint.metrics.cell_width * cursor.col as f32,
|
||||||
prepaint.bounds.origin.y + prepaint.metrics.line_height * cursor.row as f32,
|
draw_origin.y + prepaint.metrics.line_height * cursor.row as f32,
|
||||||
);
|
);
|
||||||
let mut base_style = self.base_text_style(cx);
|
let mut base_style = self.base_text_style(cx);
|
||||||
base_style.underline = Some(UnderlineStyle {
|
base_style.underline = Some(UnderlineStyle {
|
||||||
@@ -551,8 +791,8 @@ impl Element for TerminalElement {
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let x = prepaint.bounds.origin.x + prepaint.metrics.cell_width * cursor.col as f32;
|
let x = draw_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 y = draw_origin.y + prepaint.metrics.line_height * cursor.row as f32;
|
||||||
match cursor.shape {
|
match cursor.shape {
|
||||||
CursorShape::Hidden => {}
|
CursorShape::Hidden => {}
|
||||||
CursorShape::Beam => {
|
CursorShape::Beam => {
|
||||||
@@ -612,6 +852,23 @@ fn merge_rects(mut rects: Vec<LayoutRect>) -> Vec<LayoutRect> {
|
|||||||
merged
|
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 {
|
fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool {
|
||||||
let row = row.max(0) as usize;
|
let row = row.max(0) as usize;
|
||||||
let col = col.max(0) as usize;
|
let col = col.max(0) as usize;
|
||||||
@@ -629,6 +886,31 @@ fn selection_contains(selection: ViewportSelection, row: i32, col: i32) -> bool
|
|||||||
after_start && before_end
|
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 {
|
fn is_blank(cell: &alacritty_terminal::term::cell::Cell) -> bool {
|
||||||
cell.c == ' '
|
cell.c == ' '
|
||||||
&& cell.zerowidth().is_none()
|
&& cell.zerowidth().is_none()
|
||||||
@@ -720,3 +1002,65 @@ fn named_color(named: NamedColor, _foreground: bool, cx: &App) -> Hsla {
|
|||||||
NamedColor::DimWhite => Hsla::from(rgb(0xe5e9f0)),
|
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
+1294
-40
File diff suppressed because it is too large
Load Diff
+2194
-50
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