diff --git a/.github/actions/rust-cache/action.yml b/.github/actions/rust-cache/action.yml
new file mode 100644
index 0000000..309c24b
--- /dev/null
+++ b/.github/actions/rust-cache/action.yml
@@ -0,0 +1,123 @@
+name: Cargo downloads and compiled targets
+description: Share downloads across jobs without duplicating them in every target cache
+
+inputs:
+ key:
+ description: Compiled workload (core, product, release or legacy)
+ required: true
+ target:
+ description: Compiled profile directories to retain, relative to the repository
+ required: true
+ legacy-key:
+ description: Previous combined cache key, restored only during migration
+ default: ""
+ extra-key:
+ description: Additional inputs for native build tools stored with this target
+ default: ""
+
+outputs:
+ cache-key:
+ description: Key to use when saving compiled targets
+ value: ${{ steps.identity.outputs.key }}
+ cache-hit:
+ description: Whether the compiled target cache matched exactly
+ value: ${{ steps.target.outputs.cache-hit }}
+
+runs:
+ using: composite
+ steps:
+ - name: Identify compiler and build environment
+ id: identity
+ shell: bash
+ env:
+ CACHE_OS: ${{ runner.os }}
+ CACHE_ARCH: ${{ runner.arch }}
+ CACHE_WORKLOAD: ${{ inputs.key }}
+ CACHE_REVISION: ${{ github.sha }}
+ CACHE_MANIFESTS: ${{ hashFiles('Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml', '**/.cargo/config.toml', '.github/ci-profile.toml') }}${{ inputs.extra-key }}
+ run: |
+ python - <<'PY'
+ import hashlib
+ import json
+ import os
+ from pathlib import Path
+ import subprocess
+ facts = {"rustc": subprocess.check_output(["rustc", "-vV"], text=True)}
+ prefixes = ("CARGO_PROFILE_", "CFLAGS", "CXXFLAGS", "RUSTFLAGS")
+ names = {"CARGO_BUILD_TARGET", "CARGO_ENCODED_RUSTFLAGS", "RUSTC_WRAPPER",
+ "CC", "CXX", "ImageOS", "MACOSX_DEPLOYMENT_TARGET", "SDKROOT", "DEVELOPER_DIR"}
+ normalize = str.upper if os.name == "nt" else str
+ names = {normalize(name) for name in names}
+ facts["environment"] = {normalize(key): value for key, value in os.environ.items()
+ if normalize(key) in names or normalize(key).startswith(prefixes)}
+ if os.environ["CACHE_OS"] == "macOS":
+ facts["sdk"] = subprocess.check_output(["xcrun", "--sdk", "macosx", "--show-sdk-version"], text=True)
+ fingerprint = hashlib.sha256(json.dumps(facts, sort_keys=True).encode()).hexdigest()[:16]
+ prefix = "cargo-targets-v1-" + "-".join(os.environ[name] for name in
+ ("CACHE_OS", "CACHE_ARCH", "CACHE_WORKLOAD"))
+ prefix += "-" + fingerprint + "-"
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
+ # Immutable caches need a new entry after a source change, including
+ # when the prior revision failed before it compiled every profile.
+ output.write("key=" + prefix + os.environ["CACHE_MANIFESTS"] + "-" + os.environ["CACHE_REVISION"] + "\n")
+ output.write("manifest-key=" + prefix + os.environ["CACHE_MANIFESTS"] + "\n")
+ output.write("restore-key=" + prefix + "\n")
+ # Recover the preceding Windows cache, which omitted ImageOS because
+ # Python uppercases environment names there. Cargo revalidates it.
+ if os.name == "nt":
+ facts["environment"].pop("IMAGEOS", None)
+ previous = hashlib.sha256(json.dumps(facts, sort_keys=True).encode()).hexdigest()[:16]
+ output.write("migration-key=" + prefix.replace(fingerprint, previous) + "\n")
+ output.write("cargo-home=" + (os.environ.get("CARGO_HOME") or str(Path.home() / ".cargo")) + "\n")
+ PY
+ - name: Restore shared Cargo downloads
+ id: downloads
+ uses: actions/cache/restore@v4
+ with:
+ # Preserve Git source checkouts with their mtimes. Recreating them from
+ # git/db can make timestamp-based dependency fingerprints dirty even
+ # after compiled targets were restored. Registry crates remain immutable
+ # archives: Cargo can recreate their extracted sources without this risk.
+ path: |
+ ${{ steps.identity.outputs.cargo-home }}/registry/index
+ ${{ steps.identity.outputs.cargo-home }}/registry/cache
+ ${{ steps.identity.outputs.cargo-home }}/git/db
+ ${{ steps.identity.outputs.cargo-home }}/git/checkouts
+ key: cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }}
+ restore-keys: cargo-downloads-v2-${{ runner.os }}-${{ runner.arch }}-
+ - name: Restore compiled targets
+ id: target
+ uses: actions/cache/restore@v4
+ with:
+ path: ${{ inputs.target }}
+ key: ${{ steps.identity.outputs.key }}
+ restore-keys: |
+ ${{ steps.identity.outputs.manifest-key }}
+ ${{ steps.identity.outputs.restore-key }}
+ ${{ steps.identity.outputs.migration-key }}
+ - name: Recover an existing combined cache during migration
+ if: steps.target.outputs.cache-matched-key == '' && inputs.legacy-key != ''
+ uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: ${{ inputs.legacy-key }}
+ workspaces: . -> target
+ cache-bin: false
+ cache-on-failure: true
+ save-if: false
+ - name: Populate complete downloads before sharing the cache
+ if: steps.downloads.outputs.cache-hit != 'true'
+ shell: bash
+ run: |
+ host_target="$(rustc -vV | sed -n 's/^host: //p')"
+ test -n "$host_target"
+ cargo fetch --locked --target "$host_target"
+ - name: Save complete shared Cargo downloads
+ if: steps.downloads.outputs.cache-hit != 'true'
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ ${{ steps.identity.outputs.cargo-home }}/registry/index
+ ${{ steps.identity.outputs.cargo-home }}/registry/cache
+ ${{ steps.identity.outputs.cargo-home }}/git/db
+ ${{ steps.identity.outputs.cargo-home }}/git/checkouts
+ key: ${{ steps.downloads.outputs.cache-primary-key }}
diff --git a/.github/ci-profile.toml b/.github/ci-profile.toml
new file mode 100644
index 0000000..509b709
--- /dev/null
+++ b/.github/ci-profile.toml
@@ -0,0 +1,23 @@
+# Test compilation only; packaging continues to use profile.release.
+# Named dependency overrides take precedence over "*", so reset those too.
+[profile.ci]
+inherits = "dev"
+opt-level = 0
+debug = 0
+debug-assertions = true
+overflow-checks = true
+split-debuginfo = "off"
+incremental = false
+codegen-units = 256
+lto = false
+
+[profile.ci.package]
+"*" = { opt-level = 0 }
+resvg = { opt-level = 0 }
+rustybuzz = { opt-level = 0 }
+taffy = { opt-level = 0 }
+ttf-parser = { opt-level = 0 }
+smol = { opt-level = 0 }
+gpui = { opt-level = 0 }
+gpui_macros = { opt-level = 0 }
+nebula_terminal = { opt-level = 0 }
diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml
index 6a5b6b2..3e6298e 100644
--- a/.github/workflows/linux-lua.yml
+++ b/.github/workflows/linux-lua.yml
@@ -1,45 +1,179 @@
-name: Linux Lua configuration
+name: Full native tests
on:
+ workflow_dispatch:
+ inputs:
+ windows_diagnostics_run:
+ description: Diagnose a previous Windows runtime without running a release validation
+ type: string
+ default: ""
+ workflow_call:
push:
+ # Releases call this entire workflow instead of duplicating branch-push jobs.
+ branches-ignore: ["release/**"]
paths:
- "**/*.rs"
+ - "scripts/**/*.py"
- "**/Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/workflows/linux-lua.yml"
+ - ".github/ci-profile.toml"
+ - ".github/actions/rust-cache/action.yml"
pull_request:
paths:
- "**/*.rs"
+ - "scripts/**/*.py"
- "**/Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/workflows/linux-lua.yml"
+ - ".github/ci-profile.toml"
+ - ".github/actions/rust-cache/action.yml"
permissions:
contents: read
+ actions: read
jobs:
- ubuntu-24-04:
- runs-on: ubuntu-24.04
+ windows-diagnostics:
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_diagnostics_run != '' }}
+ uses: ./.github/workflows/windows-conformance.yml
+ with:
+ runtime_run: ${{ inputs.windows_diagnostics_run }}
+
+ native-tests:
+ if: ${{ inputs.windows_diagnostics_run == '' }}
+ name: Tests (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-24.04, windows-2022, macos-26, macos-26-intel]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 120
+ env:
+ PYTHONUTF8: "1"
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: "1"
+ RUST_TOOLCHAIN: "1.97.1"
steps:
- uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
- name: Install native dependencies
+ if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
- build-essential cmake pkg-config libasound2-dev libdbus-1-dev libudev-dev \
+ build-essential cmake pkg-config zsh libasound2-dev libdbus-1-dev libudev-dev \
libfontconfig1-dev libfreetype6-dev \
libegl1-mesa-dev libgl1-mesa-dev \
libwayland-dev libxkbcommon-dev libxkbcommon-x11-dev \
libx11-xcb-dev libxcb1-dev libxcb-render0-dev \
libxcb-shape0-dev libxcb-xfixes0-dev libxcb-xinput-dev libxcb-xkb-dev libxi-dev
+ - name: Require the AppKit SDK used by this PR
+ if: runner.os == 'macOS'
+ run: |
+ sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
+ test "${sdk_version%%.*}" -ge 26
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: "1.97.1"
- - uses: Swatinem/rust-cache@v2
- - name: Test Lua configuration on Wayland and X11 build
+ - uses: ./.github/actions/rust-cache
+ id: rust-cache
+ with:
+ key: native
+ extra-key: ${{ runner.os != 'macOS' && '-release-check-v2' || '' }}
+ target: |
+ target/ci
+ ${{ runner.os != 'macOS' && 'target/release' || '' }}
+ legacy-key: ${{ runner.os == 'Windows' && 'windows' || runner.os == 'Linux' && 'linux' || 'macos' }}
+ - name: Prepare pinned Windows console runtime
+ if: runner.os == 'Windows'
+ shell: pwsh
+ run: ./scripts/prepare-windows-runtime.ps1 -Destination assets/windows/conhost
+ - name: Test complete workspace and native harness
+ run: python scripts/ci_native_tests.py
+ - name: Check source architecture
+ if: ${{ !cancelled() && runner.os == 'Linux' }}
+ run: python scripts/check_architecture.py
+ - name: Test translation and allocation contracts
+ if: ${{ !cancelled() && runner.os == 'Linux' }}
+ run: cargo test --locked --manifest-path tools/i18n-contract/Cargo.toml
+ - name: Check the release workspace
+ if: ${{ !cancelled() && runner.os != 'macOS' }}
+ run: cargo check --locked --workspace --release
+ - name: Save compiled workload
+ if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ target/ci
+ ${{ runner.os != 'macOS' && 'target/release' || '' }}
+ key: ${{ steps.rust-cache.outputs.cache-key }}
+ - name: Upload native compilation timings
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: native-timings-${{ matrix.os }}
+ path: target/cargo-timings
+ if-no-files-found: warn
+ compression-level: 0
+ retention-days: 14
+
+ # The release check has no dependency on the test executable. Keeping it in
+ # the Intel test job added 11m33s after a 22m29s test/compile step in run
+ # 34856491552. Both jobs remain part of this reusable workflow's result, so
+ # the stable release aggregation still requires every check to succeed.
+ macos-release-check:
+ if: ${{ inputs.windows_diagnostics_run == '' }}
+ name: Release workspace (${{ matrix.os }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-26, macos-26-intel]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 120
+ env:
+ PYTHONUTF8: "1"
+ CARGO_TERM_COLOR: always
+ RUST_BACKTRACE: "1"
+ RUST_TOOLCHAIN: "1.97.1"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ - name: Require the AppKit SDK used by this PR
run: |
- cargo test --locked -p nebula config::
- - name: Check Linux release build
- run: cargo check --workspace --release
+ sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
+ test "${sdk_version%%.*}" -ge 26
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: "1.97.1"
+ - uses: ./.github/actions/rust-cache
+ id: rust-cache
+ with:
+ key: native-release-check
+ target: target/release
+ legacy-key: macos
+ - name: Check the release workspace
+ run: cargo check --locked --workspace --release --timings
+ - name: Save compiled workload
+ if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: target/release
+ key: ${{ steps.rust-cache.outputs.cache-key }}
+ - name: Upload release check compilation timings
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-check-timings-${{ matrix.os }}
+ path: target/cargo-timings
+ if-no-files-found: warn
+ compression-level: 0
+ retention-days: 14
diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml
index 3a80e45..426ddd2 100644
--- a/.github/workflows/preview-packages.yml
+++ b/.github/workflows/preview-packages.yml
@@ -302,10 +302,10 @@ jobs:
fail-fast: false
matrix:
include:
- - runner: macos-15
+ - runner: macos-26
architecture: aarch64
native_architecture: arm64
- - runner: macos-15-intel
+ - runner: macos-26-intel
architecture: x86_64
native_architecture: x86_64
runs-on: ${{ matrix.runner }}
@@ -323,6 +323,11 @@ jobs:
- name: Verify native runner architecture
run: test "$(uname -m)" = "${{ matrix.native_architecture }}"
+ - name: Require the modern AppKit SDK for native window controls
+ run: |
+ sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
+ test "${sdk_version%%.*}" -ge 26
+
- name: Test native DMG capacity and mounted contents
run: python3 -m unittest scripts.tests.test_macos_dmg -v
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1807f4a..70cf187 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -106,6 +106,7 @@ jobs:
python3 scripts/check_platform_cfg.py
python3 -m unittest \
scripts.tests.test_stable_release \
+ scripts.tests.test_ci_native_tests \
scripts.tests.test_preview_release \
scripts.tests.test_macos_dmg \
scripts.tests.test_ai_session_probe \
@@ -118,6 +119,15 @@ jobs:
bash -n scripts/package-linux.sh scripts/package-macos.sh
python3 -m py_compile scripts/stable_release.py scripts/preview_release.py
+ native-tests:
+ name: Native tests
+ permissions:
+ contents: read
+ actions: read
+ if: ${{ !inputs.windows_only }}
+ needs: prepare
+ uses: ./.github/workflows/linux-lua.yml
+
linux:
name: Linux x86_64 stable package
if: ${{ !inputs.windows_only }}
@@ -148,18 +158,20 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- - uses: Swatinem/rust-cache@v2
+ - uses: ./.github/actions/rust-cache
+ id: rust-cache
with:
- cache-on-failure: true
- - name: Test workspace and startup contracts
- run: |
- cargo test --locked --workspace
- cargo test --locked -p nebula --bin pebrel --features gpui-shell
- python3 -m unittest scripts.tests.test_shell_integration
- cargo test --locked -p nebula --features gpui-test-support dialog
- cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::
+ key: release
+ target: target/release
+ legacy-key: linux
- name: Build GPUI release binary
- run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell
+ run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell --timings
+ - name: Save compiled workload
+ if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: target/release
+ key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Download pinned linuxdeploy
env:
LINUXDEPLOY: ${{ runner.temp }}/linuxdeploy-x86_64.AppImage
@@ -228,6 +240,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: stable-package-linux-x64
path: |
dist/*.AppImage
@@ -239,6 +252,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: stable-evidence-linux-x64
path: dist/conformance
if-no-files-found: warn
@@ -253,11 +267,11 @@ jobs:
fail-fast: false
matrix:
include:
- - runner: macos-15
+ - runner: macos-26
architecture: aarch64
native_architecture: arm64
asset_architecture: arm64
- - runner: macos-15-intel
+ - runner: macos-26-intel
architecture: x86_64
native_architecture: x86_64
asset_architecture: x64
@@ -272,23 +286,29 @@ jobs:
python-version: "3.13"
- name: Verify native runner architecture
run: test "$(uname -m)" = "${{ matrix.native_architecture }}"
+ - name: Require the modern AppKit SDK for native window controls
+ run: |
+ sdk_version="$(xcrun --sdk macosx --show-sdk-version)"
+ test "${sdk_version%%.*}" -ge 26
- name: Test native DMG capacity and mounted contents
run: python3 -m unittest scripts.tests.test_macos_dmg -v
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- - uses: Swatinem/rust-cache@v2
+ - uses: ./.github/actions/rust-cache
+ id: rust-cache
with:
- cache-on-failure: true
- - name: Test workspace and native startup contracts
- run: |
- cargo test --locked --workspace
- cargo test --locked -p nebula --bin pebrel --features gpui-shell
- python3 -m unittest scripts.tests.test_shell_integration scripts.conformance.tests.test_posix_lifecycle
- cargo test --locked -p nebula --features gpui-test-support dialog
- cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::
+ key: release
+ target: target/release
+ legacy-key: macos
- name: Build GPUI release binary
- run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell
+ run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell --timings
+ - name: Save compiled workload
+ if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: target/release
+ key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Import Developer ID signing credentials
if: ${{ env.MACOS_SIGNING == 'developer-id' }}
env:
@@ -371,6 +391,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: stable-package-macos-${{ matrix.asset_architecture }}
path: dist/*.dmg
if-no-files-found: error
@@ -379,6 +400,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: stable-evidence-macos-${{ matrix.architecture }}
path: dist/conformance
if-no-files-found: warn
@@ -400,6 +422,7 @@ jobs:
shell: pwsh
run: |
Copy-Item scripts/prepare-windows-runtime.ps1 "$env:RUNNER_TEMP/prepare-windows-runtime.ps1"
+ Copy-Item scripts/conformance/windows_standard_user.py "$env:RUNNER_TEMP/windows-standard-user.py"
New-Item -ItemType Directory -Path "$env:RUNNER_TEMP/runtime-tests" -Force | Out-Null
Copy-Item scripts/tests/windows-runtime.tests.ps1 "$env:RUNNER_TEMP/runtime-tests/windows-runtime.tests.ps1"
- name: Verify the previous Windows test result
@@ -418,9 +441,9 @@ jobs:
$run = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN" -Headers $headers -ErrorAction Stop
if ($run.head_sha -ne $env:SELECTED_COMMIT) { throw 'Validated run source differs.' }
$jobs = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN/jobs?per_page=100" -Headers $headers -ErrorAction Stop
- $windows = @($jobs.jobs | Where-Object name -eq 'Windows x64 stable package')
- $tests = @($windows.steps | Where-Object { $_.name -eq 'Test workspace and native harness' -and $_.conclusion -eq 'success' })
- if ($windows.Count -ne 1 -or $tests.Count -ne 1) { throw 'Prior Windows native tests did not pass.' }
+ $windows = @($jobs.jobs | Where-Object { $_.name -in @('Windows x64 stable package', 'Native tests (windows-x64)') -or $_.name -match 'Tests \(windows-2022\)$' })
+ $tests = @($windows.steps | Where-Object { $_.name -in @('Test workspace and native harness', 'Test complete workspace and native harness') -and $_.conclusion -eq 'success' })
+ if ($tests.Count -ne 1) { throw 'Prior Windows native tests did not pass.' }
- uses: actions/checkout@v4
if: ${{ inputs.source_commit != '' }}
with:
@@ -431,7 +454,15 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
+ - uses: ./.github/actions/rust-cache
+ id: rust-cache
+ with:
+ key: release
+ target: target/release
+ legacy-key: windows
+ if: ${{ inputs.source_commit == '' }}
- uses: Swatinem/rust-cache@v2
+ if: ${{ inputs.source_commit != '' }}
with:
cache-on-failure: true
- name: Prepare pinned Windows console runtime
@@ -440,29 +471,27 @@ jobs:
& "$env:RUNNER_TEMP/prepare-windows-runtime.ps1" -Destination assets/windows/conhost
& "$env:RUNNER_TEMP/runtime-tests/windows-runtime.tests.ps1" -ArchivePath (Join-Path ([IO.Path]::GetTempPath()) 'pebrel-conpty-source-v1.5.0.zip')
- name: Test workspace and native harness
- if: ${{ inputs.source_commit == '' }}
- shell: pwsh
- run: |
- python -m unittest scripts.conformance.tests.test_lifecycle scripts.conformance.tests.test_scrollback
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- cargo test --locked --workspace
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- cargo test --locked -p nebula --bin pebrel --features gpui-shell
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- cargo test --locked -p nebula --features gpui-test-support dialog
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ if: ${{ inputs.windows_only && inputs.source_commit == '' }}
+ run: python scripts/ci_native_tests.py
- name: Build GPUI release binary
shell: pwsh
run: |
- cargo build --locked --release -p nebula_hook --bin pebrel-hook
- if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- cargo build --locked --release -p nebula --bin pebrel --features gpui-shell
- exit $LASTEXITCODE
+ if (Test-Path scripts/build-windows-product.ps1) {
+ ./scripts/build-windows-product.ps1 -Configuration release -TargetDirectory target
+ } else {
+ cargo build --locked --release -p nebula --bin pebrel -p nebula_hook --bin pebrel-hook --features nebula/gpui-shell
+ if ($LASTEXITCODE -ne 0) { throw 'Windows product build failed' }
+ }
+ - name: Save compiled workload
+ if: ${{ always() && steps.rust-cache.outcome == 'success' && steps.rust-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: target/release
+ key: ${{ steps.rust-cache.outputs.cache-key }}
- name: Retain built Windows runtime before packaging
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: stable-runtime-windows-x64
path: |
target/release/pebrel.exe
@@ -491,12 +520,13 @@ jobs:
$actual = git rev-parse HEAD
if ($actual -ne $env:SELECTED_COMMIT) { throw 'Checked-out source differs from the requested build.' }
$env:GITHUB_SHA = $actual
- python scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json
+ python "$env:RUNNER_TEMP/windows-standard-user.py" scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json
exit $LASTEXITCODE
- name: Upload Windows stable assets
if: always()
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: stable-package-windows-x64
path: |
dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-x64.zip
@@ -507,6 +537,7 @@ jobs:
if: always()
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: stable-evidence-windows-x64
path: dist/conformance
if-no-files-found: warn
@@ -515,7 +546,7 @@ jobs:
aggregate:
name: Verify stable assets and upload complete set
if: ${{ !inputs.windows_only }}
- needs: [prepare, linux, macos, windows]
+ needs: [prepare, native-tests, linux, macos, windows]
runs-on: ubuntu-22.04
env:
PACKAGE_VERSION: ${{ needs.prepare.outputs.version }}
@@ -554,6 +585,7 @@ jobs:
- name: Upload complete stable release set
uses: actions/upload-artifact@v4
with:
+ compression-level: 0
name: Pebrel-v${{ needs.prepare.outputs.version }}
path: release-dist
if-no-files-found: error
diff --git a/.github/workflows/windows-conformance.yml b/.github/workflows/windows-conformance.yml
new file mode 100644
index 0000000..cd2fcc5
--- /dev/null
+++ b/.github/workflows/windows-conformance.yml
@@ -0,0 +1,56 @@
+name: Windows conformance diagnostics
+
+on:
+ workflow_call:
+ inputs:
+ runtime_run:
+ description: Release run whose Windows runtime artifact should be diagnosed
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ diagnose:
+ runs-on: windows-2022
+ timeout-minutes: 10
+ env:
+ PYTHONUTF8: "1"
+ NEBULA_RESIZE_TRACE: "1"
+ NEBULA_BOOT_TRACE: "1"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.13"
+ - uses: actions/download-artifact@v4
+ with:
+ name: stable-runtime-windows-x64
+ run-id: ${{ inputs.runtime_run }}
+ github-token: ${{ github.token }}
+ path: diagnostic-runtime
+ - name: Test native permission helpers
+ run: python -m unittest scripts.conformance.tests.test_windows_standard_user -v
+ - name: Read runtime file permissions
+ if: always()
+ shell: pwsh
+ run: |
+ Get-Acl diagnostic-runtime/pebrel.exe | Format-List
+ Get-Acl $env:pythonLocation/python.exe | Format-List
+ - name: Test containment with an ordinary token
+ if: always()
+ run: python scripts/conformance/windows_standard_user.py -m unittest scripts.conformance.tests.test_lifecycle -v
+ - name: Diagnose the previously built runtime
+ if: always()
+ shell: pwsh
+ run: |
+ python scripts/conformance/windows_standard_user.py scripts/conformance/run.py --app diagnostic-runtime/pebrel.exe --platform windows-x86_64 --output diagnostic-output/windows-report.json
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: windows-conformance-diagnostics
+ path: diagnostic-output
+ if-no-files-found: warn
+ retention-days: 7
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7cad15..17201b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,45 +4,110 @@ Every release entry is provided in English and Simplified Chinese.
每个版本条目均同时提供英文和简体中文说明。
-## Unreleased / 未发布
+## 1.8.0 - 2026-09-14
### English
#### Added
+- Added confirmation before restoring default settings, with Cancel, Escape and backdrop dismissal available before any preferences change. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#115](https://github.com/Kuddev/pebrel/pull/115).
+- Added Windows login-startup and silent-start switches in Settings → Advanced → Session lifecycle. Silent startup enables the tray so the window can be reopened; an explicit directory launch still opens a visible window. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#117](https://github.com/Kuddev/pebrel/pull/117).
+- Added an independent interface text-size control, adjustable from 10 to 24 px without changing terminal text size. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127).
+- Added a separate terminal font fallback list for Chinese and other full-width characters. Font choices apply on Enter or leaving the field; the English and Chinese fields both default to bundled Maple Mono NF CN. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127).
- Added “Focus follows mouse” in Settings → Interaction, off by default, and “Dim inactive panes” in Settings → Appearance, on by default. Both preferences take effect immediately.
- Added right-click actions to the Shell launcher for choosing the default Shell and, on Windows, opening the selected Shell inside a new administrator Pebrel window.
- Added Connect, Edit, and Delete to SSH launcher context menus. Delete uses the existing confirmation and undo flow.
- Added Trae CLI recognition and its tab icon, plus a dedicated Oh My Pi icon.
+- Added CodeBuddy CLI recognition and its color tab icon, including the `codebuddy`, `cbc`, `codebuddy-code` and `codebuddy-lowmem` commands. Prewarm commands and old welcome text alone do not establish a foreground AI session.
#### Fixed
+- Fixed font candidates being clipped near the window edge or covering their input after settings-page scrolling. The list opens above the field when needed, and both terminal-font fields align and display the current font.
+- Fixed a bare `pebrel` launch opening a new terminal in the wrong directory. The inherited launch directory is preserved, including when handing the request to an existing Windows instance.
+- Fixed restored WSL panes losing their saved guest directory during startup.
+- Fixed queued AI resume commands and session identities being lost during Shell initialization. Compact Codex resume screens are recognized without another AI message, and exiting the CLI clears the foreground identity.
+- Fixed long WSL directory titles overlapping distribution labels in the sidebar and top tabs.
+- Fixed quiet terminals retaining an intermediate startup size. The final viewport size reaches the terminal even when no more output arrives.
- Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window.
- Fixed the top Settings tab disappearing when switching to another tab. It now remains available until explicitly closed, including when tabs overflow or the tab layout changes.
- Fixed `Alt+1–9` and `Ctrl+1–9` tab shortcuts losing priority to terminal input. They now work while a terminal or CLI has focus and respect custom shortcut changes without restarting.
+- Fixed WSL filename search failing when the directory path contains spaces or shell punctuation. Cancelling a search stops its own directory scan.
+- Aligned macOS native window controls with the sidebar and Settings buttons, and preserved their reserved space in the top tab layout. Window controls follow the system appearance. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#114](https://github.com/Kuddev/pebrel/pull/114).
+- Fixed the Git drawer retaining Chinese labels when the application resolves to English. Controls, history timestamps and application notices follow the selected language; switching languages preserves the commit draft and text selection. Contributed by [@gao-jian-bin](https://github.com/gao-jian-bin) in [#118](https://github.com/Kuddev/pebrel/pull/118).
+- Fixed explicitly configured terminal selection text colors being ignored. Themes without their own selection foreground preserve that setting; themes with an explicit foreground retain their precedence. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#125](https://github.com/Kuddev/pebrel/pull/125).
+- Fixed selected text disappearing behind opaque highlights in the answer reader and other selectable text views. Text remains visible while list and sidebar selection backgrounds retain their existing appearance. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#126](https://github.com/Kuddev/pebrel/pull/126).
#### Improved
+- Reduced file browsing and filename-search memory use by starting recursive searches only when a query is entered, reusing a bounded cache and streaming paths beyond that cache. Repeated panel use releases obsolete results and cancels superseded searches. Case, whole-word and regular-expression options remain available, with visible feedback for incomplete searches and errors.
+- Reduced background-image memory use with bounded image loading and reuse while resizing the window. Replacing an image releases the previous resources; extended backgrounds retain their fit, alignment and text readability.
- Replaced the low-resolution Claude Code icon with artwork exported from SVG at 1024 pixels, and prepared Agent icons at their display size for smoother tab edges.
+- Restored monospace lettering for terminal directory labels, sidebar headers, tabs and split-pane titles while retaining the independent interface text-size setting.
+
+Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled.
### 中文
#### 新增
-- 在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。
-- Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。
-- SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。
-- 新增 Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。
+- 新增:恢复默认设置前的确认弹窗,可通过“取消”、Esc 或点击遮罩退出,确认前不会修改偏好。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#115](https://github.com/Kuddev/pebrel/pull/115) 中贡献。
+- 新增:Windows“设置 → 高级 → 会话生命周期”中的登录自启动和静默启动开关。静默启动会启用托盘以便重新打开窗口;显式打开目录时仍显示窗口。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#117](https://github.com/Kuddev/pebrel/pull/117) 中贡献。
+- 新增:独立界面字号设置,可在 10–24 px 间调整,不影响终端文字字号。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。
+- 新增:中文及其他全宽字符的独立终端字体回退列表。按回车或离开输入框后生效,中英文两项默认均采用内置 Maple Mono NF CN。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。
+- 新增:在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。
+- 新增:Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。
+- 新增:SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。
+- 新增:Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。
+- 新增:CodeBuddy CLI 识别与彩色标签图标,支持 `codebuddy`、`cbc`、`codebuddy-code`、`codebuddy-lowmem` 命令;预热命令和单独的历史欢迎文字不会被认定为前台 AI 会话。
#### 修复
-- 修复 Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。
-- 修复顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。
-- 修复 `Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。
+- 修复:字体候选列表在窗口边缘被裁切、或设置页滚动后遮住输入框的问题。空间不足时向上展开,中英文终端字体输入框保持对齐并显示当前字体。
+- 修复:不带目录参数运行 `pebrel` 时,新终端未继承实际启动目录的问题;交接给已有 Windows 实例时也会保留目录。
+- 修复:WSL 窗格恢复时,在启动过程中丢失已保存的来宾目录的问题。
+- 修复:Shell 初始化期间丢失排队的 AI 恢复命令和会话身份的问题。可识别 Codex 精简恢复界面,无需再发送 AI 消息;CLI 退出后清除前台身份。
+- 修复:WSL 长目录标题与发行版名称在侧栏、顶部标签中互相覆盖的问题。
+- 修复:安静终端停留在启动中间尺寸的问题;即使没有新的输出,也会收到布局稳定后的最终视口尺寸。
+- 修复:Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。
+- 修复:顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。
+- 修复:`Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。
+- 修复:目录路径含空格或 Shell 特殊符号时 WSL 文件名搜索失败的问题;取消搜索会停止该次目录扫描。
+- 修复:对齐 macOS 原生窗口按钮与侧栏、设置按钮,并在顶部标签布局中保留窗口按钮所需空间;窗口按钮外观跟随系统。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#114](https://github.com/Kuddev/pebrel/pull/114) 中贡献。
+- 修复:应用解析为英文时 Git 面板仍保留中文标签的问题。控件、历史时间和应用提示跟随所选语言,切换语言时保留提交草稿和文字选区。由 [@gao-jian-bin](https://github.com/gao-jian-bin) 在 [#118](https://github.com/Kuddev/pebrel/pull/118) 中贡献。
+- 修复:终端忽略用户明确设置的选中文字颜色的问题。未指定选中文字颜色的主题会保留该设置;已明确指定的主题仍保持自身优先级。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#125](https://github.com/Kuddev/pebrel/pull/125) 中贡献。
+- 修复:回答阅读器等文本视图中,选中文字被不透明高亮遮住的问题。文字保持可读,列表与侧栏的选中背景保留原有外观。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#126](https://github.com/Kuddev/pebrel/pull/126) 中贡献。
#### 改进
-- 将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。
+- 改进:降低文件浏览与文件名搜索的内存占用:输入关键词后才开始递归搜索,复用有容量上限的缓存,并流式查找缓存之外的路径。重复使用文件面板时释放过期结果、取消已被替代的搜索;保留大小写、整词和正则选项,并明确提示搜索未覆盖全部文件或发生错误。
+- 改进:降低背景图内存占用,限制图片加载成本,并在调整窗口大小时复用图像资源;替换背景图会释放旧资源,扩展背景仍保持原有的适配、对齐和文字可读性。
+- 改进:将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。
+- 改进:终端目录标签、侧栏标题、标签页及分屏标题恢复等宽字体,同时继续使用独立的界面字号设置。
+
+Windows 提供 Pebrel 安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低运行版本为 14。分子结构渲染仍处于禁用状态。
+
+### Contributors
+
+

+
+**[@WilliamWang1721](https://github.com/WilliamWang1721)** - Improved macOS window controls, reset confirmation, Windows startup settings and independent interface/terminal font controls. / 改进 macOS 窗口按钮、恢复默认确认、Windows 启动设置,以及界面字号和终端字体设置。([#114](https://github.com/Kuddev/pebrel/pull/114)、[#115](https://github.com/Kuddev/pebrel/pull/115)、[#117](https://github.com/Kuddev/pebrel/pull/117)、[#127](https://github.com/Kuddev/pebrel/pull/127))
+
+**[@gao-jian-bin](https://github.com/gao-jian-bin)** - Made Git drawer language follow application preferences while preserving editing state. / 让 Git 面板语言跟随应用设置,同时保留编辑状态。([#118](https://github.com/Kuddev/pebrel/pull/118))
+
+**[@Aschenbath](https://github.com/Aschenbath)** - Preserved terminal selection foreground colors and readable text selections in the answer reader. / 修正终端选中文字颜色,并让回答阅读器的文字选区保持可读。([#125](https://github.com/Kuddev/pebrel/pull/125)、[#126](https://github.com/Kuddev/pebrel/pull/126))
+
+
+---
+
+**SHA256**
+
+- `Pebrel-v1.8.0-windows-x64.zip`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-linux-x64-preview.deb`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
## 1.7.0 - 2026-09-12
diff --git a/Cargo.lock b/Cargo.lock
index ab39a08..bc0c95e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5087,7 +5087,7 @@ dependencies = [
[[package]]
name = "nebula"
-version = "1.7.0"
+version = "1.8.0"
dependencies = [
"ab_glyph_rasterizer",
"aes-gcm",
@@ -5174,7 +5174,7 @@ dependencies = [
[[package]]
name = "nebula-completions"
-version = "1.7.0"
+version = "1.8.0"
dependencies = [
"lscolors",
"nu-ansi-term",
@@ -5195,15 +5195,15 @@ dependencies = [
[[package]]
name = "nebula-settings"
-version = "1.7.0"
+version = "1.8.0"
[[package]]
name = "nebula-split"
-version = "1.7.0"
+version = "1.8.0"
[[package]]
name = "nebula_config"
-version = "1.7.0"
+version = "1.8.0"
dependencies = [
"log",
"nebula_config_derive",
@@ -5213,7 +5213,7 @@ dependencies = [
[[package]]
name = "nebula_config_derive"
-version = "1.7.0"
+version = "1.8.0"
dependencies = [
"log",
"nebula_config",
@@ -5230,7 +5230,7 @@ version = "0.6.0"
[[package]]
name = "nebula_terminal"
-version = "1.7.0"
+version = "1.8.0"
dependencies = [
"base64",
"bitflags 2.13.0",
diff --git a/Cargo.toml b/Cargo.toml
index 7a50e60..e20f1be 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,8 +17,8 @@ edition = "2024"
rust-version = "1.97.1"
[profile.release]
-# fat LTO 会用顶层 opt-level 重跑全程序 codegen,把下面的热路径 O3 钉子
-# 冲掉;thin + codegen-units=1 保住钉子,体积差距只有个位数百分点。
+# Thin LTO preserves package-specific optimization levels. Dependencies retain
+# one codegen unit; the application uses parallel codegen with the same O3 level.
lto = "thin"
debug = 0
strip = "symbols"
@@ -38,7 +38,7 @@ smol = { opt-level = 3 }
gpui = { opt-level = 3 }
gpui-component = { opt-level = 3 }
nebula_terminal = { opt-level = 3 }
-nebula = { opt-level = 3 }
+nebula = { opt-level = 3, codegen-units = 16 }
image = { opt-level = 3 }
# Debug 壳仍要承担真实终端负载:GPUI 布局/字体塑形和 VT 解析若以
diff --git a/INSTALL.md b/INSTALL.md
index e9dcdf8..d8d415d 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -128,7 +128,10 @@ Keep the extracted directory structure intact:
Install [rustup](https://rustup.rs) and the build dependencies for your platform.
The repository pins Rust 1.97.1 in `rust-toolchain.toml`. Linux dependency packages
are listed in `.github/workflows/release.yml`; macOS requires Xcode command-line
-tools. Windows requires Windows 10 1809+ / 11 and a supported Rust linker toolchain.
+tools with macOS SDK 26 or newer (`xcrun --sdk macosx --show-sdk-version`) for
+the native Liquid Glass window controls. This build requirement does not raise
+the macOS 14 minimum runtime version. Windows requires Windows 10 1809+ / 11
+and a supported Rust linker toolchain.
```powershell
git clone https://github.com/Kuddev/pebrel
diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md
index bd066d6..b00d368 100644
--- a/docs/architecture-decisions.md
+++ b/docs/architecture-decisions.md
@@ -478,7 +478,63 @@ settings files.
measured query latency, completeness and sustained allocation evidence. Do not
restore eager full-tree indexing to improve a synthetic latency number.
-## ADR-0014 — Native font ownership and terminal resource reclamation
+## ADR-0014 — Parallel release validation with one product build graph
+
+- **Date:** 2026-09-14
+- **Context:** The 1.7.0 Windows release job took 41m28s despite a full dependency
+ cache hit. Its logs show three application test compilations (4m44s, 9m16s and
+ 4m07s), a 15m08s release build, and 4m16s of additional dependency compilation
+ during packaging. The user requested a slowest-job target of ten minutes.
+- **Decision:** Run the complete Rust workspace with the product interaction
+ feature enabled in one unfiltered invocation. Run native tests independently
+ from package construction; asset aggregation depends on both. Each native
+ platform still runs the complete Python helper and harness suites. Separate
+ test and release cache keys prevent concurrent jobs from replacing one another's
+ compiled workload. Dependency archives and Git objects are shared within each
+ platform; each workload saves only the profiles it uses (native tests also keep
+ release-check metadata). A fallback
+ reads existing combined caches during migration. Cargo still validates source,
+ profile and feature fingerprints before reuse. Each source revision saves an
+ immutable entry while restoring compatible earlier revisions; a partially built
+ cache from a failed revision cannot prevent later successful cache updates.
+- **Scheduling:** Stable releases call the same complete four-platform native
+ workflow used for contributions, including architecture, translation allocation
+ contracts and the release-workspace check. Release branch pushes omit a duplicate
+ automatic invocation; the release aggregation still requires the called suite.
+- **Test profile:** An explicit CI-only profile removes developer-preview
+ optimization and debug information from test compilation, including named
+ dependency overrides. It retains debug assertions and overflow checks. The
+ native suite also checks the actual product feature configuration, since GPUI
+ test support changes dependency features. Release optimization is unaffected.
+- **Product compilation:** The application keeps O3 and Thin LTO and uses 16
+ codegen units to parallelize its large translation unit. Other package settings
+ retain their existing values. Both Windows packagers call one explicit builder
+ for the product and its packaged hook, preserving the same feature graph.
+ Packaging still invokes Cargo and validates source freshness and binary identity.
+- **Contract clarification:** The old packaging test required the literal
+ `--workspace --exclude nebula`, rejecting a valid explicit selection of the two
+ shipped binaries. It now verifies the actual selected packages, binaries and
+ product feature, plus failure propagation and restoration of the caller's target.
+ No test filtering, freshness bypass, size-budget increase or dependency change
+ is part of this decision.
+- **Validation:** The release pipeline runs native tests, packaging fixtures,
+ package-size/identity checks and installed or mounted conformance on all four
+ platforms. Actual job timings determine whether the target is met; cache input
+ changes and first compilation must be reported separately. A configured timeout
+ or parallel scheduling alone is not evidence of a ten-minute successful build.
+- **Windows host privileges:** The first hosted run exposed administrator-token
+ dependence in ordinary-window persistence fixtures and runtime discovery.
+ Persistence tests now explicitly select ordinary-window state while retaining
+ privileged isolation tests. Native conformance launches under a restricted copy
+ of the runner's own token, verifies that elevation was removed and preserves
+ the desktop, environment and child exit status. It does not weaken the product's
+ administrator isolation or create a separate user account. Native launch tests
+ cover elevation, literal argument passing and successful/failed child exit.
+- **Revisit condition:** Retain only changes whose complete CI run and package
+ checks pass. Reconsider codegen partitioning if artifact size or runtime
+ measurements regress, and remove redundant caches if restore/save cost grows.
+
+## ADR-0015 — Native font ownership and terminal resource reclamation
- **Status:** Existing local fixes selected for commit at the maintainer's request,
2026-09-15. This records ownership contracts, not a release or process-memory claim.
@@ -516,7 +572,7 @@ settings files.
provide equivalent process identity and resource-lifetime guarantees.
-## ADR-0015 — Completion ownership follows the active connection
+## ADR-0016 — Completion ownership follows the active connection
- **Date:** 2026-09-15
- **Context:** Launch-time Local/WSL/SSH pools do not follow a typed SSH/WSL
diff --git a/docs/lua-configuration.md b/docs/lua-configuration.md
index bd0a4a6..77ac7bd 100644
--- a/docs/lua-configuration.md
+++ b/docs/lua-configuration.md
@@ -105,6 +105,30 @@ Files successfully loaded through `require` are added to the live-reload watch
list. Extra local files can be registered with
`pebrel.add_to_config_reload_watch_list(path)`.
+## Terminal Selection Colors (TOML Compatibility)
+
+For a legacy `pebrel.toml` configuration, the GPUI terminal accepts explicit
+selection foreground and background colors in `#rrggbb` or `0xrrggbb` form:
+
+```toml
+[colors.selection]
+foreground = "#102030"
+background = "#e5e9f0"
+```
+
+Previously the GPUI adapter loaded the selection background but ignored the
+foreground, which could leave selected text unreadable against a custom
+background. It also cleared a custom foreground when applying a theme such as
+Paper that does not declare selection colors. Both values now survive loading
+and themes that do not override them. A theme that explicitly declares selection
+colors, such as Nord, retains its existing precedence.
+
+Missing or invalid foreground colors keep the existing fallback. Relative
+`CellForeground` / `CellBackground` values are not added by this change. The
+regression tests cover parsing and theme precedence; this does not change the
+answer reader's selection overlay, formula selection layout, or the default
+selection contrast. No new setting, dependency, or per-frame work is introduced.
+
## Platform Values
The module exposes stable runtime paths and platform data:
diff --git a/docs/math-rendering-test.md b/docs/math-rendering-test.md
index 58507c2..8861c10 100644
--- a/docs/math-rendering-test.md
+++ b/docs/math-rendering-test.md
@@ -2,6 +2,43 @@
这是一份给 Nebula Markdown 阅读器使用的数学渲染截图样例。本文只把成对的 `$...$` 和 `$$...$$` 识别为数学公式;裸露 TeX 命令仍然按普通 Markdown 文本显示。
+## 正文选区可读性
+
+回答阅读器与 Markdown 文本组件的选区绘制在文字上方。之前主题适配直接复用
+列表选中背景:在 MintLight、Nord 等主题中,这个背景不透明,会遮住已经绘制的
+字形。主题适配现在保留原色相,并遵循 gpui-component 对文字选区的 0.3 不透明度(alpha)
+上限;更透明的主题保持原值。列表、侧栏的实色选中背景不受影响。
+
+验证时,在 MintLight 和 Nord 中分别拖选下面这句正文,检查选中后文字仍可读、
+布局不移动,复制得到的内容与所选文字一致;再缩窄窗口检查换行后的选择。
+
+alpha beta gamma delta
+
+自动回归覆盖全部内置主题的选区颜色和透明度、主题 token 同步,以及真实
+TextView 控件在深浅主题与宽窄布局下的鼠标拖选、键盘复制。另在 Windows 200%
+缩放下对 MintLight 宽布局、Nord 窄布局做了原生窗口对照,修复前后的截图仅裁剪
+标题栏与留白,未重绘内容:
+
+
+
+原生预览可通过默认忽略的 `native_reader_selection_preview` 测试复核:先将
+`PEBREL_SELECTION_QA_DIR` 设置为新的绝对输出目录,按需设置
+`PEBREL_SELECTION_QA_THEME`(默认 MintLight)和 `PEBREL_SELECTION_QA_WIDTH`
+(默认 480),然后运行:
+
+```text
+cargo test --locked -p nebula --bin pebrel --features gpui-test-support native_reader_selection_preview -- --ignored --nocapture --test-threads=1
+```
+
+测试只对自己的原生 TextView 窗口分发选择事件,不启动终端或 AI 会话、不改系统
+剪贴板。输出目录出现 `ready.json` 后可截图,写入 `capture-complete` 文件即可
+结束预览;没有信号时最多等待约一分钟后退出。
+
+虚拟窗口测试不等同于完整产品验收;Linux/macOS 原生窗口、其他缩放和真实答案
+内容仍需对应平台检查。本修复不改变
+LaTeX 解析、公式选择时的源码回退、剪贴板格式或终端网格的配色;没有新增依赖
+或逐帧计算,颜色只在主题应用时适配。
+
## 1. 行内公式
行内公式应该和中文、英文正常混排:圆的面积是 $S=\pi r^2$,勾股定理是 $a^2+b^2=c^2$,欧拉恒等式是 $e^{i\pi}+1=0$。这里再放一个带上下标的表达式 $x_{i+1}^2+y_{j-1}^2$,检查基线、间距和换行。
diff --git a/docs/release-notes/v1.8.0.md b/docs/release-notes/v1.8.0.md
new file mode 100644
index 0000000..cd0a32b
--- /dev/null
+++ b/docs/release-notes/v1.8.0.md
@@ -0,0 +1,104 @@
+# Pebrel 1.8.0
+
+## English
+
+### Added
+
+- Added confirmation before restoring default settings, with Cancel, Escape and backdrop dismissal available before any preferences change. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#115](https://github.com/Kuddev/pebrel/pull/115).
+- Added Windows login-startup and silent-start switches in Settings → Advanced → Session lifecycle. Silent startup enables the tray so the window can be reopened; an explicit directory launch still opens a visible window. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#117](https://github.com/Kuddev/pebrel/pull/117).
+- Added an independent interface text-size control, adjustable from 10 to 24 px without changing terminal text size. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127).
+- Added a separate terminal font fallback list for Chinese and other full-width characters. Font choices apply on Enter or leaving the field; the English and Chinese fields both default to bundled Maple Mono NF CN. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#127](https://github.com/Kuddev/pebrel/pull/127).
+- Added “Focus follows mouse” in Settings → Interaction, off by default, and “Dim inactive panes” in Settings → Appearance, on by default. Both preferences take effect immediately.
+- Added right-click actions to the Shell launcher for choosing the default Shell and, on Windows, opening the selected Shell inside a new administrator Pebrel window.
+- Added Connect, Edit, and Delete to SSH launcher context menus. Delete uses the existing confirmation and undo flow.
+- Added Trae CLI recognition and its tab icon, plus a dedicated Oh My Pi icon.
+- Added CodeBuddy CLI recognition and its color tab icon, including the `codebuddy`, `cbc`, `codebuddy-code` and `codebuddy-lowmem` commands. Prewarm commands and old welcome text alone do not establish a foreground AI session.
+
+### Fixed
+
+- Fixed font candidates being clipped near the window edge or covering their input after settings-page scrolling. The list opens above the field when needed, and both terminal-font fields align and display the current font.
+- Fixed a bare `pebrel` launch opening a new terminal in the wrong directory. The inherited launch directory is preserved, including when handing the request to an existing Windows instance.
+- Fixed restored WSL panes losing their saved guest directory during startup.
+- Fixed queued AI resume commands and session identities being lost during Shell initialization. Compact Codex resume screens are recognized without another AI message, and exiting the CLI clears the foreground identity.
+- Fixed long WSL directory titles overlapping distribution labels in the sidebar and top tabs.
+- Fixed quiet terminals retaining an intermediate startup size. The final viewport size reaches the terminal even when no more output arrives.
+- Fixed Windows terminal focus consuming `Alt+F4` and `Alt+Space`. `Alt+F4` now uses the normal window-close flow, and `Alt+Space`, followed by `N`, uses the system menu to minimize the window.
+- Fixed the top Settings tab disappearing when switching to another tab. It now remains available until explicitly closed, including when tabs overflow or the tab layout changes.
+- Fixed `Alt+1–9` and `Ctrl+1–9` tab shortcuts losing priority to terminal input. They now work while a terminal or CLI has focus and respect custom shortcut changes without restarting.
+- Fixed WSL filename search failing when the directory path contains spaces or shell punctuation. Cancelling a search stops its own directory scan.
+- Aligned macOS native window controls with the sidebar and Settings buttons, and preserved their reserved space in the top tab layout. Window controls follow the system appearance. Contributed by [@WilliamWang1721](https://github.com/WilliamWang1721) in [#114](https://github.com/Kuddev/pebrel/pull/114).
+- Fixed the Git drawer retaining Chinese labels when the application resolves to English. Controls, history timestamps and application notices follow the selected language; switching languages preserves the commit draft and text selection. Contributed by [@gao-jian-bin](https://github.com/gao-jian-bin) in [#118](https://github.com/Kuddev/pebrel/pull/118).
+- Fixed explicitly configured terminal selection text colors being ignored. Themes without their own selection foreground preserve that setting; themes with an explicit foreground retain their precedence. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#125](https://github.com/Kuddev/pebrel/pull/125).
+- Fixed selected text disappearing behind opaque highlights in the answer reader and other selectable text views. Text remains visible while list and sidebar selection backgrounds retain their existing appearance. Contributed by [@Aschenbath](https://github.com/Aschenbath) in [#126](https://github.com/Kuddev/pebrel/pull/126).
+
+### Improved
+
+- Reduced file browsing and filename-search memory use by starting recursive searches only when a query is entered, reusing a bounded cache and streaming paths beyond that cache. Repeated panel use releases obsolete results and cancels superseded searches. Case, whole-word and regular-expression options remain available, with visible feedback for incomplete searches and errors.
+- Reduced background-image memory use with bounded image loading and reuse while resizing the window. Replacing an image releases the previous resources; extended backgrounds retain their fit, alignment and text readability.
+- Replaced the low-resolution Claude Code icon with artwork exported from SVG at 1024 pixels, and prepared Agent icons at their display size for smoother tab edges.
+- Restored monospace lettering for terminal directory labels, sidebar headers, tabs and split-pane titles while retaining the independent interface text-size setting.
+
+Windows packages include a Pebrel installer and portable ZIP. Linux x64 packages and macOS Apple Silicon/Intel DMGs retain their Preview designation. macOS requires version 14 or later. Molecular structure rendering remains disabled.
+
+## 中文
+
+### 新增
+
+- 新增:恢复默认设置前的确认弹窗,可通过“取消”、Esc 或点击遮罩退出,确认前不会修改偏好。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#115](https://github.com/Kuddev/pebrel/pull/115) 中贡献。
+- 新增:Windows“设置 → 高级 → 会话生命周期”中的登录自启动和静默启动开关。静默启动会启用托盘以便重新打开窗口;显式打开目录时仍显示窗口。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#117](https://github.com/Kuddev/pebrel/pull/117) 中贡献。
+- 新增:独立界面字号设置,可在 10–24 px 间调整,不影响终端文字字号。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。
+- 新增:中文及其他全宽字符的独立终端字体回退列表。按回车或离开输入框后生效,中英文两项默认均采用内置 Maple Mono NF CN。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#127](https://github.com/Kuddev/pebrel/pull/127) 中贡献。
+- 新增:在“设置 → 交互”中加入“焦点跟随鼠标”,默认关闭;在“设置 → 外观”中加入“调暗非活动窗格”,默认开启。两项设置均即时生效。
+- 新增:Shell 启动菜单新增右键操作,可设为默认 Shell;Windows 下还可在新的管理员 Pebrel 窗口内打开所选 Shell。
+- 新增:SSH 启动菜单新增“连接、编辑、删除”右键操作;删除沿用已有的确认与撤销流程。
+- 新增:Trae CLI 识别与标签图标,并为 Oh My Pi 加入独立图标。
+- 新增:CodeBuddy CLI 识别与彩色标签图标,支持 `codebuddy`、`cbc`、`codebuddy-code`、`codebuddy-lowmem` 命令;预热命令和单独的历史欢迎文字不会被认定为前台 AI 会话。
+
+### 修复
+
+- 修复:字体候选列表在窗口边缘被裁切、或设置页滚动后遮住输入框的问题。空间不足时向上展开,中英文终端字体输入框保持对齐并显示当前字体。
+- 修复:不带目录参数运行 `pebrel` 时,新终端未继承实际启动目录的问题;交接给已有 Windows 实例时也会保留目录。
+- 修复:WSL 窗格恢复时,在启动过程中丢失已保存的来宾目录的问题。
+- 修复:Shell 初始化期间丢失排队的 AI 恢复命令和会话身份的问题。可识别 Codex 精简恢复界面,无需再发送 AI 消息;CLI 退出后清除前台身份。
+- 修复:WSL 长目录标题与发行版名称在侧栏、顶部标签中互相覆盖的问题。
+- 修复:安静终端停留在启动中间尺寸的问题;即使没有新的输出,也会收到布局稳定后的最终视口尺寸。
+- 修复:Windows 终端获得焦点时吞掉 `Alt+F4` 和 `Alt+Space` 的问题。`Alt+F4` 现在进入正常窗口关闭流程,`Alt+Space` 后按 `N` 可通过系统菜单最小化窗口。
+- 修复:顶部设置标签在切换到其他标签后消失的问题。现在只有主动关闭才会移除,标签溢出或切换标签布局时也会保留。
+- 修复:`Alt+1–9` 和 `Ctrl+1–9` 标签快捷键优先级低于终端输入的问题。终端或 CLI 获得焦点时仍可切换标签,自定义快捷键的修改也无需重启即可生效。
+- 修复:目录路径含空格或 Shell 特殊符号时 WSL 文件名搜索失败的问题;取消搜索会停止该次目录扫描。
+- 修复:对齐 macOS 原生窗口按钮与侧栏、设置按钮,并在顶部标签布局中保留窗口按钮所需空间;窗口按钮外观跟随系统。由 [@WilliamWang1721](https://github.com/WilliamWang1721) 在 [#114](https://github.com/Kuddev/pebrel/pull/114) 中贡献。
+- 修复:应用解析为英文时 Git 面板仍保留中文标签的问题。控件、历史时间和应用提示跟随所选语言,切换语言时保留提交草稿和文字选区。由 [@gao-jian-bin](https://github.com/gao-jian-bin) 在 [#118](https://github.com/Kuddev/pebrel/pull/118) 中贡献。
+- 修复:终端忽略用户明确设置的选中文字颜色的问题。未指定选中文字颜色的主题会保留该设置;已明确指定的主题仍保持自身优先级。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#125](https://github.com/Kuddev/pebrel/pull/125) 中贡献。
+- 修复:回答阅读器等文本视图中,选中文字被不透明高亮遮住的问题。文字保持可读,列表与侧栏的选中背景保留原有外观。由 [@Aschenbath](https://github.com/Aschenbath) 在 [#126](https://github.com/Kuddev/pebrel/pull/126) 中贡献。
+
+### 改进
+
+- 改进:降低文件浏览与文件名搜索的内存占用:输入关键词后才开始递归搜索,复用有容量上限的缓存,并流式查找缓存之外的路径。重复使用文件面板时释放过期结果、取消已被替代的搜索;保留大小写、整词和正则选项,并明确提示搜索未覆盖全部文件或发生错误。
+- 改进:降低背景图内存占用,限制图片加载成本,并在调整窗口大小时复用图像资源;替换背景图会释放旧资源,扩展背景仍保持原有的适配、对齐和文字可读性。
+- 改进:将 Claude Code 的低分辨率图标替换为从 SVG 导出的 1024 像素图像,并按显示尺寸处理 Agent 图标,使标签图标边缘更平滑。
+- 改进:终端目录标签、侧栏标题、标签页及分屏标题恢复等宽字体,同时继续使用独立的界面字号设置。
+
+Windows 提供 Pebrel 安装器和 ZIP 便携包;Linux x64 包以及 macOS Apple Silicon/Intel DMG 继续标记为 Preview。macOS 最低运行版本为 14。分子结构渲染仍处于禁用状态。
+
+## Contributors
+
+

+
+**[@WilliamWang1721](https://github.com/WilliamWang1721)** - Improved macOS window controls, reset confirmation, Windows startup settings and independent interface/terminal font controls. / 改进 macOS 窗口按钮、恢复默认确认、Windows 启动设置,以及界面字号和终端字体设置。([#114](https://github.com/Kuddev/pebrel/pull/114)、[#115](https://github.com/Kuddev/pebrel/pull/115)、[#117](https://github.com/Kuddev/pebrel/pull/117)、[#127](https://github.com/Kuddev/pebrel/pull/127))
+
+**[@gao-jian-bin](https://github.com/gao-jian-bin)** - Made Git drawer language follow application preferences while preserving editing state. / 让 Git 面板语言跟随应用设置,同时保留编辑状态。([#118](https://github.com/Kuddev/pebrel/pull/118))
+
+**[@Aschenbath](https://github.com/Aschenbath)** - Preserved terminal selection foreground colors and readable text selections in the answer reader. / 修正终端选中文字颜色,并让回答阅读器的文字选区保持可读。([#125](https://github.com/Kuddev/pebrel/pull/125)、[#126](https://github.com/Kuddev/pebrel/pull/126))
+
+
+---
+
+**SHA256**
+
+- `Pebrel-v1.8.0-windows-x64.zip`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-windows-x64-setup.exe`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-linux-x64-preview.AppImage`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-linux-x64-preview.deb`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-linux-x64-preview.tar.gz`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-macos-arm64-preview.dmg`: `PENDING FINAL BUILD`
+- `Pebrel-v1.8.0-macos-x64-preview.dmg`: `PENDING FINAL BUILD`
diff --git a/docs/screenshots/reader-selection-opacity.png b/docs/screenshots/reader-selection-opacity.png
new file mode 100644
index 0000000..17271f0
Binary files /dev/null and b/docs/screenshots/reader-selection-opacity.png differ
diff --git a/extra/logo/README.md b/extra/logo/README.md
index 55d3678..266cf77 100644
--- a/extra/logo/README.md
+++ b/extra/logo/README.md
@@ -117,6 +117,27 @@ Trae's `trae-cli` executable name is declared by ByteDance's
under `[project.scripts]`. Detection and branding do not imply Hook, cold-start,
resume, or fork support.
+### CodeBuddy Code
+
+`ai_codebuddy.svg` contains the CodeBuddy color mark from LobeHub's icon
+collection, with only a final newline added. It was retrieved on 2026-09-14
+at the following exact revision:
+
+- Source: https://github.com/lobehub/lobe-icons/blob/3928068a986819bedd4feca1a01a9ba9c3229f3b/packages/static-svg/icons/codebuddy-color.svg
+- Collection license: [MIT](LICENSE-lobe-icons), copyright LobeHub. Product names
+ and trademarks remain with their respective owners; use identifies running
+ programs and does not imply endorsement.
+- PNG SHA-256: `56ACBB748F6B324CD3D052EFD1E76A33811B36872489DF20DCB562A3C6087DE4`
+
+`ai_codebuddy.png` is a 1024 x 1024 export made with `@resvg/resvg-js` 2.6.2
+and the same rendering settings as Claude and Trae above. Both shells preserve
+its colors on light and dark themes.
+
+The `codebuddy`, `cbc`, `codebuddy-code`, and `codebuddy-lowmem` aliases are
+published in [`@tencent-ai/codebuddy-code` 2.150.0](https://registry.npmjs.org/@tencent-ai/codebuddy-code/2.150.0).
+The `cbc-prewarm` helper is excluded. Recognition does not add session resume,
+fork commands, or AI hooks.
+
### Oh My Pi
`ai_omp.svg` is a Pebrel-drawn purple gradient π mark following the maintainer's
diff --git a/extra/logo/ai_codebuddy.png b/extra/logo/ai_codebuddy.png
new file mode 100644
index 0000000..51adca9
Binary files /dev/null and b/extra/logo/ai_codebuddy.png differ
diff --git a/extra/logo/ai_codebuddy.svg b/extra/logo/ai_codebuddy.svg
new file mode 100644
index 0000000..04b3e82
--- /dev/null
+++ b/extra/logo/ai_codebuddy.svg
@@ -0,0 +1 @@
+
diff --git a/nebula-completions/Cargo.toml b/nebula-completions/Cargo.toml
index 75e2541..d550f62 100644
--- a/nebula-completions/Cargo.toml
+++ b/nebula-completions/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula-completions"
-version = "1.7.0"
+version = "1.8.0"
edition.workspace = true
[features]
diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml
index c63513f..ec7f6e0 100644
--- a/nebula_app/Cargo.toml
+++ b/nebula_app/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula"
-version = "1.7.0"
+version = "1.8.0"
license = "GPL-3.0-or-later"
description = "Pebrel — a GPU-accelerated terminal emulator"
readme = "README.md"
@@ -15,15 +15,15 @@ path = "src/main.rs"
[dependencies.nebula_terminal]
path = "../nebula_terminal"
-version = "1.7.0"
+version = "1.8.0"
[dependencies.nebula_config_derive]
path = "../nebula_config_derive"
-version = "1.7.0"
+version = "1.8.0"
[dependencies.nebula_config]
path = "../nebula_config"
-version = "1.7.0"
+version = "1.8.0"
# 可选:GPUI UI 层(feature = "gpui-shell",模块 src/gpui_shell/)。
# GPUI/component 的版本和 Git 来源由根 Cargo.toml 统一精确固定。
@@ -67,7 +67,7 @@ optional = true
[dependencies.nebula-completions]
path = "../nebula-completions"
-version = "1.7.0"
+version = "1.8.0"
[dependencies]
ahash = { version = "0.8.6", features = ["no-rng"] }
@@ -145,7 +145,7 @@ rusqlite = { version = "0.40.2", features = ["bundled"] }
sha1 = "0.11.0"
[build-dependencies]
-nebula-settings = { path = "../nebula_settings", version = "1.7.0" }
+nebula-settings = { path = "../nebula_settings", version = "1.8.0" }
gl_generator = { version = "0.14.0", optional = true }
serde = "1"
serde_json = "1"
@@ -185,7 +185,10 @@ objc2-foundation = { version = "0.3.1", default-features = false, features = [
objc2-app-kit = { version = "0.3.1", default-features = false, features = [
"std",
"NSColorSpace",
+ "NSButton",
+ "NSControl",
"NSResponder",
+ "NSToolbar",
"NSView",
"NSWindow",
] }
diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json
index c4598bf..0a06ef3 100644
--- a/nebula_app/i18n/en-US.json
+++ b/nebula_app/i18n/en-US.json
@@ -77,6 +77,13 @@
"background_cover_description": "The image will extend beneath the title bar, window buttons, tabs, and SSH sidebar. Low-contrast images may make controls harder to see; the interface retains a minimum-opacity safeguard."
},
"font": {
+ "ui_family": "Interface font",
+ "ui_family_description": "Enter an installed font family; leave empty for the default. Applies on Enter or leaving the field.",
+ "ui_size": "Interface font size",
+ "ui_size_description": "Changes interface text and component sizing independently of terminal text.",
+ "english": "English terminal font",
+ "chinese": "Chinese terminal font",
+ "chinese_description": "Used for Chinese and other full-width characters. Enter a font family or comma-separated families; leave empty to use Maple Mono NF CN. Applies on Enter or leaving the field.",
"fallback_description": "The first family is preferred; missing glyphs fall back through the remaining families, with bundled Maple as the final safe fallback.",
"group_description": "The first family is primary; the rest are used in fallback order.",
"loading": "Enumerating system fonts...",
@@ -118,7 +125,16 @@
"dim_inactive": "Dim inactive panes",
"dim_inactive_description": "Darken other split panes to make the active pane easier to identify."
},
- "save_failed": "Could not save settings: {error}"
+ "save_failed": "Could not save settings: {error}",
+ "reset_title": "Restore default settings?",
+ "reset_description": "This will reset your settings and keyboard shortcuts. SSH hosts, credentials and history will be kept, and your current settings will be backed up. Continue?",
+ "startup": {
+ "launch_at_login": "Start at login",
+ "launch_at_login_help": "Start Pebrel when you sign in to Windows. Uses the same startup shortcut as the installer.",
+ "silent_start": "Start silently",
+ "silent_start_help": "Start in the background without showing a window. Enables the system tray; click its icon to reopen. Applies while the tray is enabled, except when opening a directory explicitly.",
+ "save_failed": "Could not change startup settings: {error}"
+ }
},
"provider": {
"test": {
@@ -385,6 +401,8 @@
"save_copy": "Save a copy"
},
"files": {
+ "path_unavailable": "The path no longer exists",
+ "delete_failed": "Could not delete: {error}",
"ignore_add": "Add to .gitignore",
"ignore_remove": "Remove from .gitignore",
"ignore_added": "Added to .gitignore: {entry}",
@@ -472,6 +490,51 @@
"imported": "Added {added} hosts; skipped {skipped} existing hosts",
"imported_partial": "Added {added} hosts, but could not update their visibility. Retry the import: {error}"
},
+ "vcs": {
+ "changes": "Changes",
+ "staged": "Staged",
+ "merge_conflicts": "Merge conflicts",
+ "no_conflicts": "No unresolved conflicts",
+ "no_changes": "No changes",
+ "no_history": "No commits yet",
+ "no_branch": "(no branch)",
+ "confirm_discard": "Discard?",
+ "discard": "Discard changes",
+ "stage": "Stage",
+ "unstage": "Unstage",
+ "resolve_in_merge_editor": "Resolve in the three-way merge editor",
+ "commit_changes": "Commit / changes",
+ "graph": "Graph",
+ "history_graph": "Commit graph",
+ "conflicts": "Conflicts",
+ "resolve_conflicts": "Resolve conflicts",
+ "commit": "Commit",
+ "commit_placeholder": "Commit message...",
+ "stage_all": "Stage all",
+ "pull": "Pull",
+ "push": "Push",
+ "push_ahead": "Push ↑{count}",
+ "follow_directory": "Follow terminal directory",
+ "not_repository": "The current directory is not in a Git/SVN repository",
+ "refresh_status": "Refresh {vcs} status",
+ "close_status": "Close {vcs} status",
+ "toggle_git": "Git status (Ctrl+Shift+G)",
+ "time_now": "just now",
+ "time_minute": "1 minute ago",
+ "time_minutes": "{count} minutes ago",
+ "time_hour": "1 hour ago",
+ "time_hours": "{count} hours ago",
+ "time_day": "1 day ago",
+ "time_days": "{count} days ago",
+ "time_month": "1 month ago",
+ "time_months": "{count} months ago",
+ "time_year": "1 year ago",
+ "time_years": "{count} years ago",
+ "directory_unavailable": "The selected directory is unavailable",
+ "following_directory": "The selected directory is unavailable; following the current directory",
+ "command_failed": "{program} failed",
+ "task_start_failed": "Could not start the version control task: {error}"
+ },
"launcher": {
"open_admin": "Open as administrator",
"set_default": "Set as default Shell",
diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json
index cc3309c..7ad5902 100644
--- a/nebula_app/i18n/zh-CN.json
+++ b/nebula_app/i18n/zh-CN.json
@@ -77,6 +77,13 @@
"background_cover_description": "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。"
},
"font": {
+ "ui_family": "界面字体",
+ "ui_family_description": "输入已安装的字体名称;留空使用默认字体。按回车或离开输入框后生效。",
+ "ui_size": "界面字号",
+ "ui_size_description": "独立调整界面文字和组件字号,不影响终端文字。",
+ "english": "英文终端字体",
+ "chinese": "中文终端字体",
+ "chinese_description": "用于中文等全宽字符。输入字体名称,多个字体以逗号分隔;留空时使用 Maple Mono NF CN。按回车或离开输入框后生效。",
"fallback_description": "第 1 项优先显示;缺失字形依次使用后续字体,内置 Maple 是最终安全兜底。",
"group_description": "第 1 项是主字体,其余按顺序回退",
"loading": "正在枚举系统字体…",
@@ -118,7 +125,16 @@
"dim_inactive": "调暗非活动窗格",
"dim_inactive_description": "降低其他分屏窗格的亮度,突出当前活动窗格。"
},
- "save_failed": "无法保存设置:{error}"
+ "save_failed": "无法保存设置:{error}",
+ "reset_title": "恢复默认设置?",
+ "reset_description": "这将重置你的设置与快捷键。SSH 主机、凭据和历史将保留,当前设置会备份。是否继续?",
+ "startup": {
+ "launch_at_login": "登录时自启动",
+ "launch_at_login_help": "登录 Windows 后启动 Pebrel,与安装器使用同一个启动快捷方式。",
+ "silent_start": "静默启动",
+ "silent_start_help": "启动后在后台运行,不显示窗口。开启时会同时启用系统托盘,点击托盘图标可重新打开窗口。托盘启用时生效,显式打开目录时除外。",
+ "save_failed": "无法更改启动设置:{error}"
+ }
},
"provider": {
"test": {
@@ -385,6 +401,8 @@
"save_copy": "保存副本"
},
"files": {
+ "path_unavailable": "路径已不存在",
+ "delete_failed": "删除失败:{error}",
"ignore_add": "加入 .gitignore",
"ignore_remove": "从 .gitignore 移除",
"ignore_added": "已写入 .gitignore:{entry}",
@@ -472,6 +490,51 @@
"imported": "已新增 {added} 台主机,跳过 {skipped} 台已有主机",
"imported_partial": "已新增 {added} 台主机,但显示状态未能更新。请重试导入:{error}"
},
+ "vcs": {
+ "changes": "变更",
+ "staged": "已暂存",
+ "merge_conflicts": "合并冲突",
+ "no_conflicts": "没有待解决的冲突",
+ "no_changes": "没有更改",
+ "no_history": "还没有提交历史",
+ "no_branch": "(无分支)",
+ "confirm_discard": "确认丢弃",
+ "discard": "丢弃改动",
+ "stage": "暂存",
+ "unstage": "取消暂存",
+ "resolve_in_merge_editor": "在三栏合并器中解决",
+ "commit_changes": "提交 / 变更",
+ "graph": "线路",
+ "history_graph": "历史线路",
+ "conflicts": "冲突",
+ "resolve_conflicts": "解决冲突",
+ "commit": "提交",
+ "commit_placeholder": "提交信息…",
+ "stage_all": "全部暂存",
+ "pull": "拉取",
+ "push": "推送",
+ "push_ahead": "推送 ↑{count}",
+ "follow_directory": "回到终端当前目录",
+ "not_repository": "当前目录不在 Git/SVN 仓库中",
+ "refresh_status": "刷新 {vcs} 状态",
+ "close_status": "关闭 {vcs} 状态",
+ "toggle_git": "Git 状态 (Ctrl+Shift+G)",
+ "time_now": "刚刚",
+ "time_minute": "1 分钟前",
+ "time_minutes": "{count} 分钟前",
+ "time_hour": "1 小时前",
+ "time_hours": "{count} 小时前",
+ "time_day": "1 天前",
+ "time_days": "{count} 天前",
+ "time_month": "1 个月前",
+ "time_months": "{count} 个月前",
+ "time_year": "1 年前",
+ "time_years": "{count} 年前",
+ "directory_unavailable": "所选目录不可用",
+ "following_directory": "所选目录不可用,已跟随当前目录",
+ "command_failed": "{program} 失败",
+ "task_start_failed": "无法启动版本控制任务: {error}"
+ },
"launcher": {
"open_admin": "以管理员身份打开",
"set_default": "设为默认 Shell",
diff --git a/nebula_app/src/agent_detection/codebuddy.toml b/nebula_app/src/agent_detection/codebuddy.toml
new file mode 100644
index 0000000..e8bb88a
--- /dev/null
+++ b/nebula_app/src/agent_detection/codebuddy.toml
@@ -0,0 +1,20 @@
+# Header and footer transcribed from the user's CodeBuddy Code 2.150.0
+# Windows/WSL screenshot (2026-09-14). This fallback covers hidden guest
+# processes; a historical banner followed by a shell prompt is not live chrome.
+id = "codebuddy"
+aliases = ["cbc", "codebuddy-code", "codebuddy-lowmem"]
+
+[[identity]]
+regex = [
+ '\bCodeBuddy Code v[0-9]+\.[0-9]+\.[0-9]+\b',
+ '(?m)^[ \t]*/agent-mode to switch[^\r\n]*\? for shortcuts[^\r\n]*← for agents[ \t]*\s*\z',
+]
+
+[[rules]]
+id = "prompt_footer_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(4)"
+line_regex = ['^[ \t]*>[ \t]*$']
+regex = ['(?m)^[ \t]*/agent-mode to switch[^\r\n]*\? for shortcuts[^\r\n]*← for agents[ \t]*\s*\z']
+not = [{ contains = ["esc to interrupt"] }]
diff --git a/nebula_app/src/agent_detection/codex.toml b/nebula_app/src/agent_detection/codex.toml
index c28a5ef..24c6249 100644
--- a/nebula_app/src/agent_detection/codex.toml
+++ b/nebula_app/src/agent_detection/codex.toml
@@ -8,6 +8,11 @@ id = "codex"
[[identity]]
contains = ["openai codex"]
+# A restored conversation can omit the startup banner. Require its branded
+# empty prompt immediately followed by the live model/effort footer at the tail.
+[[identity]]
+regex = ['(?m)^[ \t]*› Ask Codex to do anything[ \t]*\r?\n(?:[ \t]*\r?\n)*[ \t]*\S+[ \t]+(?:minimal|low|medium|high|xhigh|max)[ \t]*[·•][^\r\n]*\s*\z']
+
[[rules]]
id = "strong_blocker"
state = "blocked"
diff --git a/nebula_app/src/ai_agents.rs b/nebula_app/src/ai_agents.rs
index 4106a1f..634f0ad 100644
--- a/nebula_app/src/ai_agents.rs
+++ b/nebula_app/src/ai_agents.rs
@@ -48,10 +48,11 @@ pub enum AgentKind {
Qoder,
Maki,
Trae,
+ CodeBuddy,
}
impl AgentKind {
- pub const ALL: [Self; 26] = [
+ pub const ALL: [Self; 27] = [
Self::Claude,
Self::Codex,
Self::Gemini,
@@ -78,6 +79,7 @@ impl AgentKind {
Self::Qoder,
Self::Maki,
Self::Trae,
+ Self::CodeBuddy,
];
pub fn slug(self) -> &'static str {
@@ -108,6 +110,7 @@ impl AgentKind {
Self::Qoder => "qodercli",
Self::Maki => "maki",
Self::Trae => "trae-cli",
+ Self::CodeBuddy => "codebuddy",
}
}
@@ -139,6 +142,7 @@ impl AgentKind {
Self::Qoder => "Qoder",
Self::Maki => "Maki",
Self::Trae => "Trae CLI",
+ Self::CodeBuddy => "CodeBuddy Code",
}
}
@@ -177,6 +181,8 @@ impl AgentKind {
Self::Maki => &["maki"],
// ByteDance's trae-agent declares this console entry point.
Self::Trae => &["trae-cli"],
+ // @tencent-ai/codebuddy-code 2.150.0's interactive bin entries.
+ Self::CodeBuddy => &["codebuddy", "cbc", "codebuddy-code", "codebuddy-lowmem"],
}
}
@@ -225,6 +231,9 @@ impl AgentKind {
tokens
.filter(|token| !token.starts_with('-'))
+ // The prewarm helper also lives inside the codebuddy-code package;
+ // its parent directory alone must not identify it as a live CLI.
+ .filter(|token| launcher_stem(token) != "cbc-prewarm")
.find_map(|token| token.split(['/', '\\']).find_map(Self::parse))
}
@@ -257,7 +266,8 @@ impl AgentKind {
| Self::Kilo
| Self::Qoder
| Self::Maki
- | Self::Trae => return None,
+ | Self::Trae
+ | Self::CodeBuddy => return None,
Self::Kimi => format!("kimi --session {session_id}"),
})
}
@@ -497,6 +507,7 @@ const BUNDLED: &[(AgentKind, &str)] = &[
(AgentKind::Kilo, include_str!("agent_detection/kilo.toml")),
(AgentKind::Qoder, include_str!("agent_detection/qodercli.toml")),
(AgentKind::Maki, include_str!("agent_detection/maki.toml")),
+ (AgentKind::CodeBuddy, include_str!("agent_detection/codebuddy.toml")),
];
static CACHE: OnceLock> = OnceLock::new();
@@ -835,6 +846,60 @@ mod tests {
assert_eq!(AgentKind::Trae.fork_command("session-1"), None);
}
+ #[test]
+ fn codebuddy_entry_points_identify_the_cli_without_claiming_its_helper() {
+ for command in [
+ "codebuddy",
+ "cbc",
+ "codebuddy-code",
+ "codebuddy-lowmem",
+ r"C:\tools\CODEBUDDY.CMD",
+ "/usr/bin/cbc",
+ ] {
+ assert_eq!(AgentKind::parse(command), Some(AgentKind::CodeBuddy), "{command}");
+ }
+ for command in [
+ "npx --yes @tencent-ai/codebuddy-code",
+ "node /opt/node_modules/@tencent-ai/codebuddy-code/dist/codebuddy.js",
+ "env DEBUG=1 codebuddy --help",
+ ] {
+ assert_eq!(AgentKind::parse_command(command), Some(AgentKind::CodeBuddy), "{command}");
+ }
+ for command in [
+ "cbc-prewarm",
+ "codebuddy-helper",
+ "cat codebuddy.md",
+ "node /opt/node_modules/@tencent-ai/codebuddy-code/bin/cbc-prewarm",
+ ] {
+ assert_eq!(AgentKind::parse_command(command), None, "{command}");
+ }
+ assert_eq!(AgentKind::CodeBuddy.start_command(), None);
+ assert_eq!(AgentKind::CodeBuddy.resume_command("session-1"), None);
+ assert_eq!(AgentKind::CodeBuddy.fork_command("session-1"), None);
+ }
+
+ #[test]
+ fn codebuddy_screen_identity_requires_brand_and_live_footer() {
+ // Transcribed from the user's 2.150.0 Windows/WSL screenshot, not a
+ // runtime capture. Only the observed prompt/footer establishes idle.
+ let screen = "╭─ CodeBuddy Code v2.150.0 ─╮\nTips for getting started\n\
+ ────────────────\n> \n────────────────\n\
+ /agent-mode to switch · ? for shortcuts ← for agents";
+ assert_eq!(identify(screen), Some(AgentKind::CodeBuddy));
+ let idle = detect("codebuddy", screen).unwrap();
+ assert_eq!(idle.status, AgentStatus::Idle);
+ for text in [
+ "CodeBuddy Code is a CLI.",
+ "CodeBuddy Code v2.150.0\nuser@host:~$ ",
+ "/agent-mode to switch · ? for shortcuts ← for agents",
+ "> generic prompt\n? for shortcuts ← for agents",
+ &format!("{screen}\nuser@host:~$ "),
+ ] {
+ assert_eq!(identify(text), None, "{text}");
+ }
+ assert!(detect("codebuddy", &format!("{screen}\nuser@host:~$ ")).is_none());
+ }
+
#[test]
fn submitted_commands_resolve_agents_across_wsl_launch_forms() {
for agent in AgentKind::ALL {
@@ -918,6 +983,20 @@ mod tests {
assert_eq!(idle.status, AgentStatus::Idle);
}
+ #[test]
+ fn review_regression_restored_codex_identity_requires_live_prompt_and_footer() {
+ let live = "› Ask Codex to do anything\n\n gpt-6-astra max · /mnt/d/temp_build/project · Saved conversation";
+ assert_eq!(identify(live), Some(AgentKind::Codex));
+ for text in [
+ "› generic shell prompt",
+ "The CLI says Ask Codex to do anything.",
+ "› Ask Codex to do anything\nuser@host:~$ ",
+ "› Ask Codex to do anything\ngpt-6-astra max · /project\nuser@host:~$ ",
+ ] {
+ assert_eq!(identify(text), None, "{text}");
+ }
+ }
+
#[test]
fn branded_screen_chrome_identifies_codex_without_a_visible_host_process() {
let screen = "OpenAI Codex (v0.42.0)\n\n› Ask Codex to do anything";
diff --git a/nebula_app/src/display/input_state.rs b/nebula_app/src/display/input_state.rs
index ca52a4b..6d54e5c 100644
--- a/nebula_app/src/display/input_state.rs
+++ b/nebula_app/src/display/input_state.rs
@@ -125,7 +125,6 @@ pub(crate) fn nebula_input_from_raw_grid(
nebula_prompt_line_from_raw_grid(terminal, cursor, typed_tail, env).map(|line| line.input)
}
-#[cfg(windows)]
pub(crate) fn nebula_prompt_line_from_raw_grid(
terminal: &Term,
cursor: Point,
@@ -136,6 +135,24 @@ pub(crate) fn nebula_prompt_line_from_raw_grid(
prompt_line_snapshot(&text, typed_tail, env, terminal.nebula_prompt_active())
}
+/// Cold startup has no typed input mirror yet. Readiness is distinct from
+/// reconciling user keystrokes, which must continue to reject an empty mirror.
+pub(crate) fn nebula_shell_ready_from_raw_grid(
+ terminal: &Term,
+ env: &SuggestEnv,
+) -> bool {
+ let Some(text) = raw_grid_logical_line(terminal, terminal.grid().cursor.point) else {
+ return false;
+ };
+ if let Some(line) = prompt_line_snapshot(&text, "", env, terminal.nebula_prompt_active()) {
+ return line.input.trim().is_empty();
+ }
+ let prompt = text.trim_end();
+ let Some(marker) = prompt.chars().next_back() else { return false };
+ safe_shell_prompt_marker(prompt, marker, env)
+ && (terminal.nebula_prompt_active() || likely_prompt(prompt, marker, env))
+}
+
pub(crate) fn nebula_shell_prompt_restored_from_raw_grid(
terminal: &Term,
expected_prompt: &str,
diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs
index 3c79f90..c227cee 100644
--- a/nebula_app/src/display/mod.rs
+++ b/nebula_app/src/display/mod.rs
@@ -115,12 +115,11 @@ pub use context_menu_model::{ContextMenuAction, ContextMenuHit, ContextMenuTarge
pub(crate) use file_operations::send_to_recycle_bin;
pub(crate) use input_state::{
nebula_clear_line, nebula_input_backspace, nebula_input_char, nebula_input_delete_word,
- nebula_input_text, nebula_shell_prompt_restored_from_raw_grid,
+ nebula_input_text, nebula_prompt_line_from_raw_grid,
+ nebula_shell_prompt_restored_from_raw_grid, nebula_shell_ready_from_raw_grid,
};
#[cfg(windows)]
-pub(crate) use input_state::{
- nebula_input_from_raw_grid, nebula_prompt_line_from_raw_grid, nebula_raw_grid_row_preview,
-};
+pub(crate) use input_state::{nebula_input_from_raw_grid, nebula_raw_grid_row_preview};
pub use program_identity::AiLogo;
pub(crate) use program_identity::{
ai_logo, ai_logo_for_program, prepare_ai_logo_texture, program_icon,
diff --git a/nebula_app/src/display/program_identity.rs b/nebula_app/src/display/program_identity.rs
index 1fd6dc7..4e4a6ac 100644
--- a/nebula_app/src/display/program_identity.rs
+++ b/nebula_app/src/display/program_identity.rs
@@ -12,10 +12,11 @@ pub enum AiLogo {
Antigravity,
Trae,
OhMyPi,
+ CodeBuddy,
}
impl AiLogo {
- pub(crate) const ALL: [Self; 8] = [
+ pub(crate) const ALL: [Self; 9] = [
Self::Claude,
Self::OpenAi,
Self::OpenCode,
@@ -24,6 +25,7 @@ impl AiLogo {
Self::Antigravity,
Self::Trae,
Self::OhMyPi,
+ Self::CodeBuddy,
];
/// One source catalog for both shells. Official color assets are embedded unchanged.
@@ -38,6 +40,7 @@ impl AiLogo {
Self::Antigravity => include_bytes!("../../../extra/logo/ai_antigravity.png"),
Self::Trae => include_bytes!("../../../extra/logo/ai_trae.png"),
Self::OhMyPi => include_bytes!("../../../extra/logo/ai_omp.png"),
+ Self::CodeBuddy => include_bytes!("../../../extra/logo/ai_codebuddy.png"),
}
}
@@ -46,7 +49,12 @@ impl AiLogo {
Self::OpenAi | Self::Pi => false,
// OpenCode stores a luma map: the frame is white, the inner block gray.
Self::OpenCode => true,
- Self::Claude | Self::Grok | Self::Antigravity | Self::Trae | Self::OhMyPi => return,
+ Self::Claude
+ | Self::Grok
+ | Self::Antigravity
+ | Self::Trae
+ | Self::OhMyPi
+ | Self::CodeBuddy => return,
};
for pixel in pixels.chunks_exact_mut(4) {
let luma = if preserve_luma { u16::from(pixel[0]) } else { 255 };
@@ -123,6 +131,7 @@ pub(crate) fn ai_logo_for_program(program: &str) -> Option {
AgentKind::Antigravity => Some(AiLogo::Antigravity),
AgentKind::Trae => Some(AiLogo::Trae),
AgentKind::OhMyPi => Some(AiLogo::OhMyPi),
+ AgentKind::CodeBuddy => Some(AiLogo::CodeBuddy),
_ => None,
}
}
@@ -144,6 +153,7 @@ pub(crate) fn program_icon(program: &str) -> &'static str {
"aider" | "goose" | "crush" | "ollama" => "\u{f06a9}",
"opencode" | "trae-cli" => "\u{f489}",
"pi" | "omp" | "oh-my-pi" => "\u{f135}",
+ "codebuddy" | "cbc" | "codebuddy-code" | "codebuddy-lowmem" => "\u{f06a9}",
"git" | "lazygit" => "\u{f418}",
"vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}",
"ssh" | "mosh" => "\u{f489}",
@@ -349,4 +359,52 @@ mod tests {
assert_eq!(logo_for_command(command), Some(AiLogo::OhMyPi));
}
}
+
+ #[test]
+ fn codebuddy_commands_share_the_color_logo_without_matching_helpers() {
+ for command in [
+ "codebuddy",
+ "cbc",
+ "codebuddy-code",
+ "codebuddy-lowmem",
+ r"C:\tools\CODEBUDDY.CMD --help",
+ ] {
+ assert_eq!(logo_for_command(command), Some(AiLogo::CodeBuddy), "{command}");
+ }
+ for command in ["cbc-prewarm", "codebuddy-helper", "cat codebuddy.md"] {
+ assert_eq!(logo_for_command(command), None, "{command}");
+ }
+ }
+
+ #[test]
+ fn codebuddy_square_logo_smooths_edges_without_recoloring_the_brand() {
+ let logo = AiLogo::CodeBuddy;
+ let (width, height, source) = decode_png(logo.png(false));
+ assert_eq!((width, height), (1024, 1024));
+ let original = image::RgbaImage::from_raw(width, height, source.clone()).unwrap();
+ let partial_coverage =
+ |pixels: &[u8]| pixels.chunks_exact(4).filter(|p| p[3] > 0 && p[3] < 255).count();
+ for size in [16, 18, 24, 27, 36, 48] {
+ let (pixels, width, height) = prepare_ai_logo_texture(&source, width, height, size);
+ assert_eq!((width, height), (size, size));
+ assert!(pixels.chunks_exact(4).any(|p| p[3] == 0));
+ assert!(pixels.chunks_exact(4).any(|p| p[3] == 255));
+ // A symmetric rounded square has fewer distinct edge alpha levels
+ // than the asymmetric marks above. Compare actual fractional edge
+ // coverage with a deliberately aliased nearest-neighbor sample.
+ let aliased = image::imageops::resize(
+ &original,
+ size,
+ size,
+ image::imageops::FilterType::Nearest,
+ );
+ assert!(partial_coverage(&pixels) > partial_coverage(aliased.as_raw()), "{size}px");
+ assert!(pixels.chunks_exact(4).any(|p| p[3] > 128 && p[2] > p[0].saturating_add(50)));
+ for ink in [[236, 239, 245], [35, 40, 50]] {
+ let mut tinted = pixels.clone();
+ logo.tint_pixels(&mut tinted, ink);
+ assert_eq!(tinted, pixels);
+ }
+ }
+ }
}
diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs
index 650eae0..a9e5142 100644
--- a/nebula_app/src/display/settings.rs
+++ b/nebula_app/src/display/settings.rs
@@ -1106,14 +1106,7 @@ pub(super) fn cursor_shape_settings_value(shape: CursorShape) -> &'static str {
}
pub(crate) fn parse_hex_rgb(value: &str) -> Option {
- let hex = value.strip_prefix('#').unwrap_or(value);
- if hex.len() != 6 {
- return None;
- }
- let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
- let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
- let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
- Some(Rgb::new(r, g, b))
+ nebula_settings::parse_hex_rgb(value).map(|[r, g, b]| Rgb::new(r, g, b))
}
fn format_hex_rgb(rgb: Rgb) -> String {
diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs
index 74ede41..a4d5814 100644
--- a/nebula_app/src/display/side_panel/mod.rs
+++ b/nebula_app/src/display/side_panel/mod.rs
@@ -25,12 +25,14 @@ use unicode_width::UnicodeWidthChar;
mod enumerate;
mod gitignore;
mod icons;
+mod notice;
#[cfg(feature = "legacy-shell")]
mod render;
mod search;
#[cfg(test)]
mod tests;
mod vcs;
+pub(crate) use notice::PanelNotice;
// 子模块的项一律 `pub(crate)`:本 crate 是 bin,没有下游用户,所以拆分不必
// 把内部实现抬到 `pub`。glob 转发让 `display::side_panel::X` 这层路径不变。
@@ -395,7 +397,7 @@ pub struct SidePanel {
/// `custom_root` 互斥,且不改变终端实际 cwd / Git 跟随位置。
custom_wsl_root: Option,
/// Visible feedback for an invalid/disappeared custom root.
- root_notice: Option,
+ root_notice: Option,
/// Flattened visible tree rows for the Files view.
rows: Vec,
/// Last unfiltered tree snapshot. Search results replace `rows`, but
@@ -449,7 +451,7 @@ pub struct SidePanel {
/// Set by the worker when it finishes — `sync` folds it into a refresh.
op_done: std::sync::Arc,
/// Last operation's error (empty = success), shown on the summary line.
- op_error: std::sync::Arc>,
+ op_error: std::sync::Arc>,
/// A snapshot worker (fs walk + git subprocesses) is in flight. Guards
/// against stacking workers; a refresh requested meanwhile re-arms
/// `needs_refresh` and runs after this one lands.
@@ -637,7 +639,7 @@ impl SidePanel {
let custom_invalidated = self.custom_root.as_ref().is_some_and(|root| !root.is_dir());
if custom_invalidated {
self.custom_root = None;
- self.root_notice = Some("所选目录不可用,已跟随当前目录".to_owned());
+ self.root_notice = Some(PanelNotice::FollowingDirectory);
}
let next_root = self
.custom_root
@@ -743,7 +745,7 @@ impl SidePanel {
/// belongs to one window and this field is deliberately never serialized.
pub fn set_custom_root(&mut self, root: PathBuf) -> bool {
if !root.is_dir() {
- self.root_notice = Some("所选目录不可用".to_owned());
+ self.root_notice = Some(PanelNotice::DirectoryUnavailable);
return false;
}
let changed = self.custom_root.as_ref() != Some(&root) || self.root.as_ref() != Some(&root);
@@ -851,8 +853,12 @@ impl SidePanel {
self.scroll = 0;
}
- pub fn root_notice(&self) -> Option<&str> {
- self.root_notice.as_deref()
+ pub fn root_notice(&self) -> Option {
+ self.localized_root_notice(crate::i18n::UiLanguage::ZhCn)
+ }
+
+ pub fn localized_root_notice(&self, language: crate::i18n::UiLanguage) -> Option {
+ self.root_notice.as_ref().map(|notice| notice.text(language))
}
/// Only real Git file rows are interactive. Section headers and the blank
@@ -879,8 +885,8 @@ impl SidePanel {
}
/// 面板顶部的一句话提示(复用根目录不可用的同一条 UI)。
- pub fn set_notice(&mut self, message: String) {
- self.root_notice = Some(message);
+ pub fn set_notice(&mut self, message: impl Into) {
+ self.root_notice = Some(message.into());
}
fn current_index_root(&self) -> Option {
diff --git a/nebula_app/src/display/side_panel/notice.rs b/nebula_app/src/display/side_panel/notice.rs
new file mode 100644
index 0000000..87ddaaa
--- /dev/null
+++ b/nebula_app/src/display/side_panel/notice.rs
@@ -0,0 +1,130 @@
+//! Store application notices as outcomes so a cached result follows the UI language.
+//! Command output and user content remain verbatim.
+
+use crate::i18n::{Message, UiLanguage};
+
+#[derive(Clone, Debug)]
+pub(crate) enum PanelNotice {
+ Raw(String),
+ DirectoryUnavailable,
+ FollowingDirectory,
+ CommandFailed(String),
+ TaskStartFailed(String),
+ PathUnavailable,
+ DeleteFailed(String),
+ IgnoreAdded(String),
+ IgnoreRemoved(String),
+ IgnorePresent(String),
+ IgnoreVisible(String),
+ IgnoreFailed(String),
+}
+
+impl Default for PanelNotice {
+ fn default() -> Self {
+ Self::Raw(String::new())
+ }
+}
+
+impl From for PanelNotice {
+ fn from(value: String) -> Self {
+ Self::Raw(value)
+ }
+}
+
+impl From<&str> for PanelNotice {
+ fn from(value: &str) -> Self {
+ Self::Raw(value.to_owned())
+ }
+}
+
+impl PanelNotice {
+ pub(super) fn text(&self, language: UiLanguage) -> String {
+ match self {
+ Self::Raw(text) => text.clone(),
+ Self::DirectoryUnavailable => {
+ language.text(Message::VcsDirectoryUnavailable).to_owned()
+ },
+ Self::FollowingDirectory => language.text(Message::VcsFollowingDirectory).to_owned(),
+ Self::CommandFailed(program) => {
+ language.format(Message::VcsCommandFailed, &[("program", program)])
+ },
+ Self::TaskStartFailed(error) => {
+ language.format(Message::VcsTaskStartFailed, &[("error", error)])
+ },
+ Self::PathUnavailable => language.text(Message::FilesPathUnavailable).to_owned(),
+ Self::DeleteFailed(error) => {
+ language.format(Message::FilesDeleteFailed, &[("error", error)])
+ },
+ Self::IgnoreAdded(entry) => {
+ language.format(Message::FilesIgnoreAdded, &[("entry", entry)])
+ },
+ Self::IgnoreRemoved(entry) => {
+ language.format(Message::FilesIgnoreRemoved, &[("entry", entry)])
+ },
+ Self::IgnorePresent(entry) => {
+ language.format(Message::FilesIgnorePresent, &[("entry", entry)])
+ },
+ Self::IgnoreVisible(entry) => {
+ language.format(Message::FilesIgnoreVisible, &[("entry", entry)])
+ },
+ Self::IgnoreFailed(error) => {
+ language.format(Message::FilesIgnoreFailed, &[("error", error)])
+ },
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cached_notices_follow_language_changes_without_repeating_the_operation() {
+ let notice = PanelNotice::FollowingDirectory;
+ assert_eq!(notice.text(UiLanguage::ZhCn), "所选目录不可用,已跟随当前目录");
+ assert_eq!(
+ notice.text(UiLanguage::EnUs),
+ "The selected directory is unavailable; following the current directory"
+ );
+ assert_eq!(notice.text(UiLanguage::FrFr), notice.text(UiLanguage::EnUs));
+ assert_eq!(
+ PanelNotice::DirectoryUnavailable.text(UiLanguage::EnUs),
+ "The selected directory is unavailable"
+ );
+ assert_eq!(PanelNotice::CommandFailed("git".into()).text(UiLanguage::EnUs), "git failed");
+ assert_eq!(PanelNotice::CommandFailed("git".into()).text(UiLanguage::ZhCn), "git 失败");
+ }
+
+ #[test]
+ fn external_errors_and_placeholder_like_arguments_are_preserved() {
+ let stderr = "fatal: 无法读取 分支/{error}";
+ for language in UiLanguage::ALL {
+ assert_eq!(PanelNotice::Raw(stderr.into()).text(*language), stderr);
+ }
+ let notice = PanelNotice::TaskStartFailed("OS error: {program}".into());
+ assert_eq!(
+ notice.text(UiLanguage::EnUs),
+ "Could not start the version control task: OS error: {program}"
+ );
+ }
+
+ #[test]
+ fn file_notices_can_be_reused_by_the_git_drawer_in_a_new_language() {
+ let cases = [
+ (PanelNotice::PathUnavailable, "The path no longer exists"),
+ (PanelNotice::DeleteFailed("denied".into()), "Could not delete: denied"),
+ (PanelNotice::IgnoreAdded("/build/".into()), "Added to .gitignore: /build/"),
+ (PanelNotice::IgnoreRemoved("/build/".into()), "No longer ignored: /build/"),
+ (PanelNotice::IgnorePresent("/build/".into()), "Already ignored: /build/"),
+ (PanelNotice::IgnoreVisible("/build/".into()), "Already visible: /build/"),
+ (
+ PanelNotice::IgnoreFailed("denied".into()),
+ "Could not change Git ignore rules: denied",
+ ),
+ ];
+ for (notice, english) in cases {
+ assert_ne!(notice.text(UiLanguage::ZhCn), english);
+ assert_eq!(notice.text(UiLanguage::EnUs), english);
+ }
+ }
+}
diff --git a/nebula_app/src/display/side_panel/render.rs b/nebula_app/src/display/side_panel/render.rs
index b11c250..790dd4d 100644
--- a/nebula_app/src/display/side_panel/render.rs
+++ b/nebula_app/src/display/side_panel/render.rs
@@ -481,7 +481,7 @@ pub(crate) fn draw_text(
as usize)
.max(4);
let (summary, summary_ink) = if let Some(notice) = panel.root_notice() {
- (clip_tail(notice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b))
+ (clip_tail(¬ice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b))
} else {
(
panel
diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs
index e94e2de..be53af9 100644
--- a/nebula_app/src/display/side_panel/tests.rs
+++ b/nebula_app/src/display/side_panel/tests.rs
@@ -143,7 +143,12 @@ mod tests {
assert!(panel.sync(Some(cwd.clone())));
assert!(!panel.custom_root_active());
assert_eq!(panel.root(), Some(cwd.as_path()));
- assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录"));
+ assert_eq!(panel.root_notice().as_deref(), Some("所选目录不可用,已跟随当前目录"));
+
+ assert_eq!(
+ panel.localized_root_notice(crate::i18n::UiLanguage::EnUs).as_deref(),
+ Some("The selected directory is unavailable; following the current directory")
+ );
panel.wait_snapshot();
panel.file_index.release_for_test();
@@ -165,7 +170,7 @@ mod tests {
assert!(panel.sync(Some(root.clone())));
assert!(!panel.custom_root_active());
assert_eq!(panel.root(), Some(root.as_path()));
- assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录"));
+ assert_eq!(panel.root_notice().as_deref(), Some("所选目录不可用,已跟随当前目录"));
}
#[test]
diff --git a/nebula_app/src/display/side_panel/vcs.rs b/nebula_app/src/display/side_panel/vcs.rs
index ea01e1b..bb57bb3 100644
--- a/nebula_app/src/display/side_panel/vcs.rs
+++ b/nebula_app/src/display/side_panel/vcs.rs
@@ -252,11 +252,15 @@ impl SidePanel {
/// Last mutation's error, if any (cleared by the next successful op).
pub fn op_error(&self) -> Option {
- let e = self.op_error.lock().ok()?;
- (!e.is_empty()).then(|| e.clone())
+ self.localized_op_error(crate::i18n::UiLanguage::ZhCn)
}
- fn set_op_error(&mut self, message: impl Into) {
+ pub fn localized_op_error(&self, language: crate::i18n::UiLanguage) -> Option {
+ let text = self.op_error.lock().ok()?.text(language);
+ (!text.is_empty()).then_some(text)
+ }
+
+ fn set_op_error(&mut self, message: impl Into) {
if let Ok(mut error) = self.op_error.lock() {
*error = message.into();
}
@@ -274,7 +278,7 @@ impl SidePanel {
let done = self.op_done.clone();
let error = self.op_error.clone();
if let Ok(mut message) = error.lock() {
- message.clear();
+ *message = PanelNotice::default();
}
let display_name = program.display().to_string();
let spawn_result =
@@ -287,16 +291,16 @@ impl SidePanel {
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let msg = match cmd.output() {
- Ok(out) if out.status.success() => String::new(),
+ Ok(out) if out.status.success() => PanelNotice::default(),
Ok(out) => {
let err = String::from_utf8_lossy(&out.stderr);
// First meaningful line is enough for a status strip.
err.lines()
.find(|l| !l.trim().is_empty())
- .unwrap_or(&format!("{display_name} 失败"))
- .to_string()
+ .map(|line| PanelNotice::Raw(line.to_owned()))
+ .unwrap_or_else(|| PanelNotice::CommandFailed(display_name.clone()))
},
- Err(e) => format!("{display_name}: {e}"),
+ Err(e) => PanelNotice::Raw(format!("{display_name}: {e}")),
};
if let Ok(mut slot) = error.lock() {
*slot = msg;
@@ -306,7 +310,7 @@ impl SidePanel {
});
if let Err(spawn_error) = spawn_result {
self.op_running.store(false, Ordering::Relaxed);
- self.set_op_error(format!("无法启动版本控制任务: {spawn_error}"));
+ self.set_op_error(PanelNotice::TaskStartFailed(spawn_error.to_string()));
}
}
diff --git a/nebula_app/src/font_install.rs b/nebula_app/src/font_install.rs
index 0e701ac..972703a 100644
--- a/nebula_app/src/font_install.rs
+++ b/nebula_app/src/font_install.rs
@@ -2,13 +2,13 @@ use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
-pub const REQUIRED_FONT_FAMILY: &str = "Maple Mono Normal NF CN";
+pub const REQUIRED_FONT_FAMILY: &str = "Maple Mono NF CN";
/// The bundled face is shared by the legacy rasterizer and the GPUI text
/// system. Keeping one static byte slice avoids letting the two shells drift
/// to different font revisions.
pub static REQUIRED_FONT_BYTES: &[u8] =
- include_bytes!("../../assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf");
+ include_bytes!("../../assets/fonts/MapleMono-NF-CN-Regular.ttf");
/// 一个系统已安装字体族,连同平台给出的等宽判定。
///
@@ -214,7 +214,7 @@ pub fn gpui_font_with_fallbacks(value: &str) -> gpui::Font {
}
font
}
-pub const REQUIRED_FONT_FILE: &str = "MapleMonoNormal-NF-CN-Regular.ttf";
+pub const REQUIRED_FONT_FILE: &str = "MapleMono-NF-CN-Regular.ttf";
/// 枚举系统已安装字体族,带 DirectWrite 的权威等宽判定(`IsMonospacedFont`)。
///
diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs
index b14d378..cda4ee1 100644
--- a/nebula_app/src/gpui_shell/config.rs
+++ b/nebula_app/src/gpui_shell/config.rs
@@ -43,6 +43,7 @@ pub struct Settings {
/// 已解析的界面语言。GPUI 组件只读这个内存全局,渲染路径不得重复读盘。
pub ui_language: UiLanguage,
pub font_family: String,
+ pub font_cjk: Option<[gpui::Font; 4]>,
pub font_bold_family: String,
pub font_italic_family: String,
pub font_bold_italic_family: String,
@@ -51,6 +52,9 @@ pub struct Settings {
/// 配置文件的基准字号,不含设置页/Ctrl+滚轮持久化的终端缩放。
/// 启动窗口按它定形,和旧壳的 `window_size` 契约一致。
pub base_font_size_px: f32,
+ pub ui_font_size_px: f32,
+ pub(crate) ui_font_family: Option,
+ pub(crate) ui_font_size_override: Option,
/// 字体 cell 的物理像素偏移;旧壳 Windows 默认 y=4,必须在设备像素
/// 域参与取整,才能在 125%/150% DPI 下保持同一行数。
pub font_offset_x: f32,
@@ -244,6 +248,9 @@ impl Settings {
font_bold_italic_family: secondary(&raw.font.bold_italic),
font_size_px,
base_font_size_px,
+ ui_font_size_px: runtime.ui_font_size_px.unwrap_or(base_font_size_px),
+ ui_font_family: runtime.ui_font_family.clone(),
+ ui_font_size_override: runtime.ui_font_size_px,
font_offset_x: f32::from(offset.x),
font_offset_y: f32::from(offset.y),
theme_line_height,
@@ -280,6 +287,24 @@ impl Settings {
cjk_bold_regular: runtime.cjk_bold_regular,
shell_id: runtime.shell.clone(),
font_family: normal_family,
+ font_cjk: Some({
+ let family = runtime
+ .font_family_cjk
+ .as_deref()
+ .unwrap_or(crate::font_install::REQUIRED_FONT_FAMILY);
+ use gpui::{FontStyle, FontWeight};
+ [
+ (FontWeight::NORMAL, FontStyle::Normal),
+ (FontWeight::BOLD, FontStyle::Normal),
+ (FontWeight::NORMAL, FontStyle::Italic),
+ (FontWeight::BOLD, FontStyle::Italic),
+ ]
+ .map(|(weight, style)| gpui::Font {
+ weight,
+ style,
+ ..crate::font_install::gpui_font_with_fallbacks(family)
+ })
+ }),
load_notice,
// 这里是唯一能正确合并「主题自带几何」与「用户显式覆盖」的地方:
// `theme` 已是 follow_system 折算后的**生效**主题,runtime 是同一次
@@ -390,7 +415,9 @@ fn apply_resolved_theme(palette: &mut Palette, resolved: &crate::gpui_shell::the
if let Some(selection) = exact.selection_background {
palette.selection = rgba8(selection);
}
- palette.selection_foreground = exact.selection_foreground.map(rgba8);
+ if let Some(selection_foreground) = exact.selection_foreground {
+ palette.selection_foreground = Some(rgba8(selection_foreground));
+ }
return;
}
if term.is_light {
@@ -686,6 +713,7 @@ fn build_palette(raw: &RawColors) -> Palette {
// 用户显式选区色保持主应用的不透明语义。
palette.selection = selection;
}
+ palette.selection_foreground = raw.selection.foreground.as_deref().and_then(parse_rgb);
let ansi8 = |group: &RawAnsi8| -> [Option; 8] {
[
@@ -739,8 +767,9 @@ fn build_palette(raw: &RawColors) -> Palette {
#[cfg(test)]
mod tests {
use super::{
- Settings, apply_resolved_theme, apply_theme, effective_font_sizes,
- effective_theme_line_height, resolve_ui_language, rgba8, runtime_background,
+ RawColors, Settings, apply_resolved_theme, apply_theme, build_palette,
+ effective_font_sizes, effective_theme_line_height, resolve_ui_language, rgba8,
+ runtime_background,
};
use crate::display::UiLanguage;
use crate::gpui_shell::terminal::colors::Palette;
@@ -791,6 +820,68 @@ mod tests {
assert_eq!(palette.selection_foreground, None);
}
+ #[test]
+ fn explicit_selection_colors_are_loaded_together() {
+ let raw: RawColors = toml::from_str(
+ r##"
+ [selection]
+ foreground = "#102030"
+ background = "0xe5e9f0"
+ "##,
+ )
+ .unwrap();
+ let palette = build_palette(&raw);
+
+ assert_eq!(palette.selection_foreground, Some(rgba8([0x10, 0x20, 0x30])));
+ assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0]));
+ }
+
+ #[test]
+ fn invalid_or_relative_selection_foreground_keeps_default() {
+ for foreground in ["#invalid", "#fff", "CellForeground", "CellBackground"] {
+ let raw: RawColors = toml::from_str(&format!(
+ "[selection]\nforeground = {foreground:?}\nbackground = \"#e5e9f0\"\n"
+ ))
+ .unwrap();
+ let palette = build_palette(&raw);
+
+ assert_eq!(palette.selection_foreground, None, "{foreground}");
+ assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0]));
+ }
+ }
+
+ #[test]
+ fn theme_without_selection_colors_preserves_user_foreground() {
+ let foreground = rgba8([0x10, 0x20, 0x30]);
+ let background = rgba8([0xe5, 0xe9, 0xf0]);
+ for theme in [ThemeName::Nebula, ThemeName::Paper] {
+ let mut palette = Palette {
+ selection_foreground: Some(foreground),
+ selection: background,
+ ..Palette::default()
+ };
+
+ apply_theme(&mut palette, theme);
+
+ assert_eq!(palette.selection_foreground, Some(foreground));
+ assert_eq!(palette.selection, background);
+ }
+ }
+
+ #[test]
+ fn theme_with_selection_colors_keeps_its_existing_precedence() {
+ let mut palette = Palette {
+ selection_foreground: Some(rgba8([1, 2, 3])),
+ selection: rgba8([4, 5, 6]),
+ ..Palette::default()
+ };
+
+ apply_theme(&mut palette, ThemeName::Nord);
+
+ assert_eq!(palette.selection_foreground, Some(rgba8([0x2e, 0x34, 0x40])));
+ assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0]));
+ }
+
#[test]
fn themes_apply_only_the_colors_they_declare() {
let mut nord = Palette::default();
diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs
index a9ca5f5..791e4db 100644
--- a/nebula_app/src/gpui_shell/mod.rs
+++ b/nebula_app/src/gpui_shell/mod.rs
@@ -120,13 +120,14 @@ pub fn run_shell(
{
// 与另一份进程同时启动时,对方可能已经拿到 owner lock、但尚未来得及
// 发布 endpoint。短暂等待并交接,避免继续打开一个无控制面的空窗口。
+ let handover_cwd = initial_cwd.clone().or_else(|| std::env::current_dir().ok());
for _ in 0..40 {
let behavior = nebula_settings::RuntimeSettings::load().windowing_behavior;
let handed_over = match behavior {
nebula_settings::WindowingBehaviorName::UseNew => {
- crate::runtime_api::try_open_window_existing(initial_cwd.as_deref())
+ crate::runtime_api::try_open_window_existing(handover_cwd.as_deref())
},
- _ => initial_cwd.as_deref().map_or_else(
+ _ => handover_cwd.as_deref().map_or_else(
crate::runtime_api::try_open_default_tab_existing,
crate::runtime_api::try_open_directory_existing,
),
@@ -152,7 +153,11 @@ pub fn run_shell(
#[cfg(windows)]
crate::ai_hook::spawn_config_guard();
init(cx);
- cx.activate(true);
+ if initial_cwd.is_some()
+ || !crate::platform::startup::start_hidden(&nebula_settings::RuntimeSettings::load())
+ {
+ cx.activate(true);
+ }
open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd, initial_command);
});
crate::tray::shutdown();
@@ -195,9 +200,10 @@ fn register_bundled_fonts(cx: &App) {
// GPUI resolves a family through the system collection first and silently
// falls back when it is absent. Add Maple before any component/window can
// resolve a font so the default remains the same private face as winit.
- if let Err(error) =
- cx.text_system().add_fonts(vec![Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES)])
- {
+ if let Err(error) = cx.text_system().add_fonts(vec![
+ Cow::Borrowed(crate::font_install::REQUIRED_FONT_BYTES),
+ Cow::Borrowed(include_bytes!("../../../assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf")),
+ ]) {
try_write_stderr(format_args!(
"[nebula:gpui] failed to register bundled Maple font: {error}"
));
diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs
index 55a3b55..335bb6c 100644
--- a/nebula_app/src/gpui_shell/settings_pane.rs
+++ b/nebula_app/src/gpui_shell/settings_pane.rs
@@ -96,6 +96,7 @@ pub struct SettingsPane {
pub(super) focus_handle: FocusHandle,
/// 渲染与写盘的单一事实源;每次 persist 后整体重载。
pub(super) runtime: RuntimeSettings,
+ launch_at_login: bool,
/// 当前分区(`SECTIONS` 下标);默认落在应用主页。
active_section: usize,
appearance_picker: Option,
@@ -193,6 +194,7 @@ pub struct SettingsPane {
/// GPUI text system 已注册的导入族名(启动扫描 + 本次导入累计)。
font_imported: Vec,
font_family_input: Entity,
+ font_family_cjk_input: Entity,
/// 字体输入框上一帧的窗口坐标。字体目录是宽弹层,不能把整条设置行当
/// 锚点;否则输入框在右侧、菜单却会从正文左缘展开。
font_picker_trigger_bounds: Option>,
@@ -341,6 +343,33 @@ impl SettingsPane {
self.request_cover_chrome(value, window, cx);
return;
}
+ if key == "launch_at_login" || key == "silent_start" {
+ let result = if key == "launch_at_login" {
+ crate::platform::startup::set_launch_at_login(value).map(|()| {
+ self.launch_at_login = value;
+ })
+ } else {
+ let mut updates = vec![(key, (value as u8).to_string())];
+ if value {
+ updates.push(("tray", "1".to_owned()));
+ }
+ self.try_persist(&updates, cx)
+ };
+ if let Err(error) = result {
+ let language = crate::gpui_shell::config::ui_language(cx);
+ super::toast::toast(
+ window,
+ cx,
+ super::toast::ToastKind::Warning,
+ language.format(
+ crate::i18n::Message::SettingsStartupSaveFailed,
+ &[("error", &error.to_string())],
+ ),
+ );
+ }
+ cx.notify();
+ return;
+ }
if matches!(key, "ai_toasts" | "focus_follows_mouse" | "dim_inactive_panes") {
if let Err(error) = self.try_persist(&[(key, (value as u8).to_string())], cx) {
let language = crate::gpui_shell::config::ui_language(cx);
@@ -421,14 +450,9 @@ impl SettingsPane {
self.sync_background_color_picker(window, cx);
}
- /// Workspace tab 与设置正文共用的主文字字号事实源;导航使用组件小字号。
- ///
- /// 旧壳合同(display/mod.rs `ui_font_px`):chrome 排版锚定**配置字号**
- /// (nebula.toml `font.size`,默认 11.25pt = 15px),终端的持久化缩放
- /// (设置 spinner / Ctrl+滚轮写入的 `font_size=`)只影响终端网格,
- /// 不得放大侧栏与设置文字。
+ /// 界面独立字号;未设置时保留原配置字号,终端缩放不影响这里。
fn font_size_px(&self, cx: &App) -> f32 {
- cx.global::().base_font_size_px
+ cx.global::().ui_font_size_px
}
/// 终端字号(预览与「终端字号」步进行显示的值)。
@@ -436,11 +460,6 @@ impl SettingsPane {
cx.global::().font_size_px
}
- fn set_font_size(&mut self, size: f32, cx: &mut Context) {
- let size = size.clamp(4.0, 96.0);
- self.persist(&[("font_size", format!("{size:.2}"))], cx);
- }
-
/// 拖拽期间只做"改 alpha + 重绘"这两件必须的事,落盘与整套热应用
/// 交给 [`Self::schedule_slider_persist`]。旧壳拖 opacity 就是改内存 + 重绘,
/// 这条快路径是为了对回那个手感。
@@ -1179,6 +1198,24 @@ impl SettingsPane {
// `platform::capabilities` 的说明)。
let caps = crate::platform::CAPABILITIES;
self.group(language.pick("会话生命周期", "Session lifecycle"), cx)
+ .when(caps.launch_at_login, |group| {
+ group.child(self.switch_row(
+ "launch_at_login",
+ language.text(crate::i18n::Message::SettingsStartupLaunchAtLogin),
+ language.text(crate::i18n::Message::SettingsStartupLaunchAtLoginHelp),
+ self.launch_at_login,
+ cx,
+ ))
+ })
+ .when(caps.hide_window_on_close, |group| {
+ group.child(self.switch_row(
+ "silent_start",
+ language.text(crate::i18n::Message::SettingsStartupSilentStart),
+ language.text(crate::i18n::Message::SettingsStartupSilentStartHelp),
+ self.runtime.silent_start,
+ cx,
+ ))
+ })
.when(caps.hide_window_on_close, |group| {
group.child(self.switch_row(
"keep_session",
@@ -1338,7 +1375,7 @@ impl SettingsPane {
.px_3()
.text_color(muted)
.tooltip(language.pick("恢复设置与快捷键;保留 SSH 主机、凭据和历史,并备份原设置。", "Restores settings and shortcuts. Keeps SSH hosts, credentials and history, and backs up current settings."))
- .on_click(cx.listener(|this, _, window, cx| this.reset_all_settings(window, cx))),
+ .on_click(cx.listener(|this, _, window, cx| this.confirm_reset_all_settings(window, cx))),
)
.into_any_element()
}
diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance.rs b/nebula_app/src/gpui_shell/settings_pane/appearance.rs
index cc64676..015f8f8 100644
--- a/nebula_app/src/gpui_shell/settings_pane/appearance.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/appearance.rs
@@ -107,45 +107,51 @@ impl SettingsPane {
.into_any_element()
}
- pub(super) fn terminal_font_size_row(&self, cx: &Context) -> gpui::AnyElement {
+ pub(super) fn font_size_row(&self, ui: bool, cx: &Context) -> gpui::AnyElement {
let language = crate::gpui_shell::config::ui_language(cx);
- let size = self.terminal_font_size_px(cx);
+ let size = if ui { self.font_size_px(cx) } else { self.terminal_font_size_px(cx) };
+ let (key, min, max) =
+ if ui { ("ui_font_size", 10.0, 24.0) } else { ("font_size", 4.0, 96.0) };
let stepper = h_flex()
.w(px(142.0))
.h(px(36.0))
.items_center()
.child(
- Button::new("appearance-font-smaller")
+ Button::new(SharedString::from(format!("{key}-smaller")))
.icon(IconName::Minus)
.ghost()
.size(px(34.0))
- .disabled(size <= 4.0)
+ .disabled(size <= min)
.tooltip(language.pick("减小字号", "Decrease font size"))
- .on_click(cx.listener(|this, _, _, cx| {
- this.set_font_size(
- (this.terminal_font_size_px(cx).ceil() - 1.0).round(),
- cx,
- );
+ .on_click(cx.listener(move |this, _, _, cx| {
+ let next = (size.ceil() - 1.0).clamp(min, max);
+ this.persist(&[(key, format!("{next:.2}"))], cx);
})),
)
.child(div().flex_1().text_center().child(format!("{size:.0} px")))
.child(
- Button::new("appearance-font-larger")
+ Button::new(SharedString::from(format!("{key}-larger")))
.icon(IconName::Plus)
.ghost()
.size(px(34.0))
- .disabled(size >= 96.0)
+ .disabled(size >= max)
.tooltip(language.pick("增大字号", "Increase font size"))
- .on_click(cx.listener(|this, _, _, cx| {
- this.set_font_size(
- (this.terminal_font_size_px(cx).floor() + 1.0).round(),
- cx,
- );
+ .on_click(cx.listener(move |this, _, _, cx| {
+ let next = (size.floor() + 1.0).clamp(min, max);
+ this.persist(&[(key, format!("{next:.2}"))], cx);
})),
);
self.row(
- language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)"),
- language.pick("只调整终端文字大小。", "Changes only the terminal text size."),
+ if ui {
+ language.text(crate::i18n::Message::SettingsFontUiSize)
+ } else {
+ language.pick("终端字号(Ctrl+滚轮缩放)", "Terminal font size (Ctrl+wheel)")
+ },
+ if ui {
+ language.text(crate::i18n::Message::SettingsFontUiSizeDescription)
+ } else {
+ language.pick("只调整终端文字大小。", "Changes only the terminal text size.")
+ },
stepper,
cx,
)
diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs
index 5716f7c..03d32a5 100644
--- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs
@@ -61,6 +61,7 @@ impl SettingsPane {
));
let interface = self
.group(language.pick("界面", "Interface"), cx)
+ .child(self.font_size_row(true, cx))
.child(self.switch_row(
"dim_inactive_panes",
language.text(crate::i18n::Message::SettingsPanesDimInactive),
@@ -103,12 +104,28 @@ impl SettingsPane {
let terminal = self
.group(language.pick("终端外观", "Terminal appearance"), cx)
.child(self.row(
- language.pick("终端字体", "Terminal font"),
+ language.text(crate::i18n::Message::SettingsFontEnglish),
help("font_family", language),
font_picker,
cx,
))
- .child(self.terminal_font_size_row(cx))
+ .child(
+ self.row(
+ language.text(crate::i18n::Message::SettingsFontChinese),
+ language.text(crate::i18n::Message::SettingsFontChineseDescription),
+ div()
+ .debug_selector(|| "font-family-cjk-input".to_owned())
+ .w(px(SETTINGS_SELECT_WIDTH))
+ .h(px(36.0))
+ .child(
+ Input::new(&self.font_family_cjk_input).w_full().h_full().aria_label(
+ language.text(crate::i18n::Message::SettingsFontChinese),
+ ),
+ ),
+ cx,
+ ),
+ )
+ .child(self.font_size_row(false, cx))
.child(self.select_row(
"cell_width_mode",
language.pick("字体间距", "Character spacing"),
diff --git a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs
index 2005838..3edbd62 100644
--- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs
@@ -3,6 +3,7 @@ use super::*;
use crate::font_install::{FontSource, REQUIRED_FONT_FAMILY};
const FONT_PICKER_LIST_PREFERRED_HEIGHT: f32 = 220.0;
+const FONT_PICKER_PANEL_WIDTH: f32 = 360.0;
const FONT_PICKER_PANEL_CHROME_HEIGHT: f32 = 74.0;
const FONT_PICKER_OFFSET_Y: f32 = 6.0;
const FONT_PICKER_WINDOW_MARGIN: f32 = 8.0;
@@ -199,14 +200,14 @@ impl SettingsPane {
.relative()
.w(px(SETTINGS_SELECT_WIDTH))
.min_w_0()
- .h(px(32.0))
- .when(self.active_section == 1, |control| control.w_full().h(px(36.0)))
+ .h(px(36.0))
+ .debug_selector(|| "font-family-input".to_owned())
.flex_shrink_0()
.overflow_hidden()
.child(
Input::new(&self.font_family_input)
.w_full()
- .when(self.active_section == 1, |input| input.h(px(36.0)))
+ .h(px(36.0))
.cleanable(false)
.suffix(Button::new("font-picker-chevron")
.debug_selector(|| "font-picker-chevron".to_owned()).ghost().xsmall()
@@ -220,7 +221,7 @@ impl SettingsPane {
cx.stop_propagation();
cx.notify();
})))
- .aria_label(language.pick("终端字体", "Terminal font")),
+ .aria_label(language.text(crate::i18n::Message::SettingsFontEnglish)),
)
// 弹层仍须取输入框的真实窗口坐标,才能正确处理滚动、缩放与 DPI。
.child(
@@ -241,13 +242,14 @@ impl SettingsPane {
|_, _, _, _| {},
)
.absolute()
+ .inset_0()
.size_full(),
);
if self.active_section == 1 {
return control.into_any_element();
}
self.row(
- language.pick("终端字体", "Terminal font"),
+ language.text(crate::i18n::Message::SettingsFontEnglish),
help("font_family", language),
control,
cx,
@@ -401,6 +403,7 @@ impl SettingsPane {
families.iter().any(|family| family.eq_ignore_ascii_case(&entry.name));
h_flex()
.id(SharedString::from(format!("font-available-row-{index}")))
+ .debug_selector(move || format!("font-available-{index}"))
.h(px(38.0))
.w_full()
.min_w_0()
@@ -451,7 +454,9 @@ impl SettingsPane {
let available_empty = available_rows.is_empty();
v_flex()
- // 与输入字段使用同一真实宽度,右缘和左右边界都不会跳变。
+ // The menu needs room for family names and ordering actions; the trigger
+ // remains aligned with the other settings fields.
+ .debug_selector(|| "font-picker-panel".to_owned())
.w(width)
.max_w_full()
.p_3()
@@ -486,7 +491,7 @@ impl SettingsPane {
)
})
.when(!self.font_loading, |panel| {
- panel.child(v_flex().h(list_height).min_h_0().overflow_y_scrollbar().child(
+ panel.child(v_flex().debug_selector(|| "font-picker-list".to_owned()).h(list_height).min_h_0().overflow_y_scrollbar().child(
v_flex()
.w_full()
// gpui-component 的纵向滚动条以 16px 绝对定位覆盖在
@@ -527,7 +532,7 @@ impl SettingsPane {
})
}
- /// 可编辑字体字段及其同宽延迟弹层;较长内容只在列表内部滚动。
+ /// Keep the menu inside the window, preferring the side with room for candidates.
pub(super) fn font_picker_dropdown(
&mut self,
window: &mut Window,
@@ -535,39 +540,56 @@ impl SettingsPane {
) -> gpui::Div {
let row = self.font_picker_row(cx);
let trigger_bounds = self.font_picker_trigger_bounds;
- let panel_width = trigger_bounds
- .as_ref()
- .map(|bounds| bounds.size.width)
- .unwrap_or(px(SETTINGS_SELECT_WIDTH));
- let list_height = trigger_bounds
+ let viewport = window.viewport_size();
+ let panel_width = px(FONT_PICKER_PANEL_WIDTH
+ .min((f32::from(viewport.width) - 2.0 * FONT_PICKER_WINDOW_MARGIN).max(0.0)));
+ let (open_above, list_height) = trigger_bounds
.as_ref()
.map(|bounds| {
- let viewport_height = f32::from(window.viewport_size().height);
- let trigger_bottom = f32::from(bounds.origin.y + bounds.size.height);
- let available_below = (viewport_height
- - trigger_bottom
+ let below = (f32::from(viewport.height - bounds.bottom_right().y)
- FONT_PICKER_OFFSET_Y
- FONT_PICKER_WINDOW_MARGIN)
.max(0.0);
-
- // 头部保持稳定,只压缩候选区。弹层因此不会再因超出窗口
- // 而被整体吸附到输入框上方,候选区仍有明确高度承接滚轮。
- px((available_below - FONT_PICKER_PANEL_CHROME_HEIGHT)
- .clamp(0.0, FONT_PICKER_LIST_PREFERRED_HEIGHT))
+ let above =
+ (f32::from(bounds.origin.y) - FONT_PICKER_OFFSET_Y - FONT_PICKER_WINDOW_MARGIN)
+ .max(0.0);
+ let preferred = FONT_PICKER_PANEL_CHROME_HEIGHT + FONT_PICKER_LIST_PREFERRED_HEIGHT;
+ let open_above = below < preferred && above > below;
+ let available = if open_above { above } else { below };
+ (
+ open_above,
+ px((available - FONT_PICKER_PANEL_CHROME_HEIGHT)
+ .clamp(0.0, FONT_PICKER_LIST_PREFERRED_HEIGHT)),
+ )
})
- .unwrap_or(px(FONT_PICKER_LIST_PREFERRED_HEIGHT));
+ .unwrap_or((false, px(FONT_PICKER_LIST_PREFERRED_HEIGHT)));
let panel =
self.font_picker_open.then(|| self.font_picker_panel(panel_width, list_height, cx));
- div().relative().w_full().flex_shrink_0().child(row).when_some(
+ div().relative().w(px(SETTINGS_SELECT_WIDTH)).flex_shrink_0().child(row).when_some(
panel.zip(trigger_bounds),
|anchor, (panel, trigger_bounds)| {
anchor.child(
deferred(
anchored()
- .anchor(gpui::Anchor::TopRight)
- .position(trigger_bounds.bottom_right())
- .offset(gpui::point(px(0.0), px(FONT_PICKER_OFFSET_Y)))
+ .anchor(if open_above {
+ gpui::Anchor::BottomRight
+ } else {
+ gpui::Anchor::TopRight
+ })
+ .position(if open_above {
+ trigger_bounds.top_right()
+ } else {
+ trigger_bounds.bottom_right()
+ })
+ .offset(gpui::point(
+ px(0.0),
+ px(if open_above {
+ -FONT_PICKER_OFFSET_Y
+ } else {
+ FONT_PICKER_OFFSET_Y
+ }),
+ ))
.snap_to_window_with_margin(px(FONT_PICKER_WINDOW_MARGIN))
.child(panel),
)
@@ -585,7 +607,9 @@ mod interaction_tests {
use gpui_component::Root;
#[gpui::test]
- fn dropdown_arrow_toggles_and_search_filters_without_navigation_click(cx: &mut TestAppContext) {
+ fn review_regression_font_fields_align_and_dropdown_toggles_with_search(
+ cx: &mut TestAppContext,
+ ) {
cx.update(|cx| {
gpui_component::init(cx);
cx.set_global(crate::gpui_shell::config::Settings::load(
@@ -593,7 +617,7 @@ mod interaction_tests {
));
});
let mut pane = None;
- let (_, mut cx) = cx.add_window_view(|window, cx| {
+ let (_, cx) = cx.add_window_view(|window, cx| {
let view = cx.new(|cx| SettingsPane::new(window, cx));
pane = Some(view.clone());
Root::new(view, window, cx)
@@ -604,6 +628,22 @@ mod interaction_tests {
cx.update(|window, cx| {
let _ = window.draw(cx);
});
+ let english = cx.debug_bounds("font-family-input").expect("English field is visible");
+ let chinese = cx.debug_bounds("font-family-cjk-input").expect("Chinese field is visible");
+ assert_eq!(english.size.width, px(SETTINGS_SELECT_WIDTH));
+ assert_eq!(english.size, chinese.size, "both font fields share the same dimensions");
+ assert_eq!(
+ english.origin.x, chinese.origin.x,
+ "both font fields align in the control column"
+ );
+ assert_eq!(
+ pane.read_with(cx, |pane, cx| pane.font_family_input.read(cx).value().to_string()),
+ REQUIRED_FONT_FAMILY
+ );
+ assert_eq!(
+ pane.read_with(cx, |pane, cx| pane.font_family_cjk_input.read(cx).value().to_string()),
+ REQUIRED_FONT_FAMILY
+ );
let bounds =
cx.debug_bounds("font-picker-chevron").expect("font field exposes a dropdown arrow");
let center = point(
@@ -616,6 +656,37 @@ mod interaction_tests {
cx.simulate_click(center, gpui::Modifiers::default());
cx.run_until_parked();
assert!(!pane.read_with(cx, |pane, _| pane.font_picker_open));
+ // At the real 144-DPI window's logical height, placing the menu below
+ // the font field used to hide every available family under its header.
+ cx.simulate_resize(gpui::size(px(1280.0), px(735.0)));
+ cx.run_until_parked();
+ let bounds = cx.debug_bounds("font-picker-chevron").unwrap();
+ cx.simulate_click(bounds.center(), gpui::Modifiers::default());
+ cx.run_until_parked();
+ cx.update(|window, cx| {
+ let _ = window.draw(cx);
+ });
+ let trigger = cx.debug_bounds("font-family-input").unwrap();
+ let panel = cx.debug_bounds("font-picker-panel").unwrap();
+ let list = cx.debug_bounds("font-picker-list").unwrap();
+ let candidate = cx.debug_bounds("font-available-0").unwrap();
+ assert!(
+ panel.origin.y >= px(0.0) && panel.bottom_right().y <= px(735.0),
+ "font menu stays within the window: {panel:?}"
+ );
+ assert_eq!(panel.size.width, px(FONT_PICKER_PANEL_WIDTH));
+ assert!(
+ panel.bottom_right().y <= trigger.origin.y
+ || panel.origin.y >= trigger.bottom_right().y,
+ "menu must not cover the field after focus scrolling: panel={panel:?}, trigger={trigger:?}"
+ );
+ assert!(
+ candidate.origin.y >= list.origin.y
+ && candidate.bottom_right().y <= list.bottom_right().y,
+ "at least the first available family is fully visible without scrolling: list={list:?}, candidate={candidate:?}, trigger={trigger:?}"
+ );
+ cx.simulate_click(bounds.center(), gpui::Modifiers::default());
+ cx.run_until_parked();
cx.update(|window, cx| {
pane.update(cx, |pane, cx| {
pane.settings_search_input
diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs
index 40b1cee..3f539d8 100644
--- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs
@@ -458,6 +458,40 @@ impl SettingsPane {
},
));
+ let font_family_cjk_input = cx.new(|cx| {
+ InputState::new(window, cx).default_value(
+ runtime
+ .font_family_cjk
+ .clone()
+ .unwrap_or_else(|| crate::font_install::REQUIRED_FONT_FAMILY.to_owned()),
+ )
+ });
+ subscriptions.push(cx.subscribe_in(
+ &font_family_cjk_input,
+ window,
+ |this: &mut Self, input, event: &InputEvent, window, cx| {
+ if matches!(event, InputEvent::PressEnter { .. } | InputEvent::Blur) {
+ let raw = input.read(cx).value();
+ let normalized = crate::font_install::normalize_font_family_chain(&raw);
+ let value = if normalized.is_empty() {
+ crate::font_install::REQUIRED_FONT_FAMILY.to_owned()
+ } else {
+ normalized
+ };
+ let current = this
+ .runtime
+ .font_family_cjk
+ .as_deref()
+ .unwrap_or(crate::font_install::REQUIRED_FONT_FAMILY);
+ let changed = current != value;
+ input.update(cx, |input, cx| input.set_value(value.clone(), window, cx));
+ if changed {
+ this.persist(&[("font_family_cjk", value)], cx);
+ }
+ }
+ },
+ ));
+
let bg_picker_hsv = {
let term = crate::gpui_shell::theme::resolved_palette(cx).term_bg;
let rgb = runtime.background.unwrap_or([term.r, term.g, term.b]);
@@ -503,6 +537,7 @@ impl SettingsPane {
Self {
focus_handle: cx.focus_handle(),
runtime,
+ launch_at_login: crate::platform::startup::launch_at_login(),
active_section: 1,
appearance_picker: None,
appearance_picker_seq: 0,
@@ -574,6 +609,7 @@ impl SettingsPane {
font_system: None,
font_imported: Vec::new(),
font_family_input,
+ font_family_cjk_input,
font_picker_trigger_bounds: None,
backup_selection: crate::encrypted_backup::BackupSelection::default(),
backup_pass_input: cx.new(|cx| {
diff --git a/nebula_app/src/gpui_shell/settings_pane/navigation.rs b/nebula_app/src/gpui_shell/settings_pane/navigation.rs
index a08ad19..ff468cb 100644
--- a/nebula_app/src/gpui_shell/settings_pane/navigation.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/navigation.rs
@@ -34,7 +34,7 @@ pub(super) const SECTION_SEARCH_TERMS: [&str; 10] = [
"network 网络 proxy 代理 connectivity 连接",
"interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板 focus follows mouse 焦点跟随鼠标 自动聚焦",
"keymap key binding shortcut quick terminal 快速终端 独立窗口 已有窗口 按键映射 快捷键",
- "advanced 高级 session 会话 tray 托盘 restore 恢复",
+ "advanced 高级 session 会话 tray 托盘 restore 恢复 startup autostart login silent 自启动 静默启动 开机 登录",
"backup 备份 export 导出 restore 恢复",
];
diff --git a/nebula_app/src/gpui_shell/settings_pane/reset.rs b/nebula_app/src/gpui_shell/settings_pane/reset.rs
index f567aad..f168fa3 100644
--- a/nebula_app/src/gpui_shell/settings_pane/reset.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/reset.rs
@@ -1,7 +1,35 @@
use super::*;
impl SettingsPane {
- pub(super) fn reset_all_settings(&mut self, window: &mut Window, cx: &mut Context) {
+ pub(super) fn confirm_reset_all_settings(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ use crate::i18n::Message;
+
+ let language = crate::gpui_shell::config::ui_language(cx);
+ let pane = cx.entity().downgrade();
+ window.open_dialog(cx, move |dialog, window, _cx| {
+ let pane = pane.clone();
+ confirm_dialog(
+ dialog,
+ window,
+ language.text(Message::SettingsResetTitle),
+ language.text(Message::SettingsResetDescription),
+ language.text(Message::CommonRestoreDefaults),
+ language.text(Message::CommonCancel),
+ ButtonVariant::Danger,
+ )
+ .on_ok(move |_, window, cx| {
+ let _ = pane.update(cx, |this, cx| this.reset_all_settings(window, cx));
+ true
+ })
+ });
+ cx.notify();
+ }
+
+ fn reset_all_settings(&mut self, window: &mut Window, cx: &mut Context) {
let language = crate::gpui_shell::config::ui_language(cx);
self.slider_persist = None;
let backup = match nebula_settings::restore_default_settings() {
diff --git a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs
index 6e3eeb1..2b7a074 100644
--- a/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs
+++ b/nebula_app/src/gpui_shell/settings_pane/theme_studio_tests.rs
@@ -13,6 +13,15 @@ use nebula_settings::{RawSettings, RuntimeSettings, ThemeDefinition, ThemeName};
const TEST_SETTINGS: &str =
"theme=Nord\nfollow_system_theme=0\napp_icon=graphite-violet\nfont_size=15\n";
+// These rendered fixtures share the real settings path and theme library.
+// Readers must hold the same guard as Save/Apply tests so their before/after
+// snapshots cannot observe another fixture's writes or restoration cleanup.
+// Only this fixture group is serialized; the rest of the native suite stays parallel.
+fn lock_theme_studio() -> std::sync::MutexGuard<'static, ()> {
+ static FIXTURES: std::sync::Mutex<()> = std::sync::Mutex::new(());
+ FIXTURES.lock().unwrap_or_else(|error| error.into_inner())
+}
+
#[derive(Clone, Debug, PartialEq)]
struct RuntimeSnapshot {
theme: ThemeName,
@@ -357,6 +366,7 @@ fn editor_template(pane: &Entity, cx: &mut VisualTestContext) -> T
fn theme_editor_opens_from_the_picker_with_common_fields_and_collapsed_advanced(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let (_pane, mut window) = open_settings(cx);
open_theme_editor(&mut window);
@@ -387,6 +397,7 @@ fn theme_editor_opens_from_the_picker_with_common_fields_and_collapsed_advanced(
fn theme_editor_input_is_a_local_draft_until_apply_and_preserves_builtin_source(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
let before_palette = palette_snapshot(&mut window);
@@ -409,6 +420,7 @@ fn theme_editor_input_is_a_local_draft_until_apply_and_preserves_builtin_source(
#[gpui::test]
fn theme_editor_template_keyboard_selection_replaces_only_the_draft(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
open_theme_editor(&mut window);
@@ -430,6 +442,7 @@ fn theme_editor_template_keyboard_selection_replaces_only_the_draft(cx: &mut Tes
fn theme_editor_dirty_back_and_escape_show_confirmation_and_keep_or_discard_draft(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
let before_palette = palette_snapshot(&mut window);
@@ -453,6 +466,7 @@ fn theme_editor_dirty_back_and_escape_show_confirmation_and_keep_or_discard_draf
#[gpui::test]
fn theme_editor_back_returns_to_picker_and_preserves_its_draft(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
click("open-theme-picker", &mut window);
click_first_theme_option(&mut window);
@@ -483,6 +497,7 @@ fn theme_editor_back_returns_to_picker_and_preserves_its_draft(cx: &mut TestAppC
fn theme_editor_save_only_persists_a_copy_without_changing_source_selection(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
let before_palette = palette_snapshot(&mut window);
@@ -532,6 +547,7 @@ fn theme_editor_save_only_persists_a_copy_without_changing_source_selection(
#[gpui::test]
fn theme_editor_save_and_apply_persists_copy_and_reloads_its_foreground(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let _settings_guard = SettingsBytesGuard::capture();
std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS)
.expect("write isolated apply settings");
@@ -593,6 +609,7 @@ fn theme_editor_save_and_apply_persists_copy_and_reloads_its_foreground(cx: &mut
#[gpui::test]
fn invalid_color_stays_dirty_and_save_rejects_after_a_later_valid_edit(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
let before_palette = palette_snapshot(&mut window);
@@ -627,6 +644,7 @@ fn invalid_color_stays_dirty_and_save_rejects_after_a_later_valid_edit(cx: &mut
#[gpui::test]
fn non_finite_font_values_stay_out_of_the_draft_and_save_is_rejected(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
let before_palette = palette_snapshot(&mut window);
@@ -655,6 +673,7 @@ fn non_finite_font_values_stay_out_of_the_draft_and_save_is_rejected(cx: &mut Te
#[gpui::test]
fn theme_editor_font_select_changes_only_the_draft(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
pane.update(&mut window, |pane, _| {
// Keep the real selector independent of the fonts installed on the host.
@@ -716,6 +735,7 @@ fn theme_editor_font_select_changes_only_the_draft(cx: &mut TestAppContext) {
#[gpui::test]
fn theme_editor_color_picker_is_draft_only_and_cancel_restores(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
let before_palette = palette_snapshot(&mut window);
@@ -774,6 +794,7 @@ fn theme_editor_color_picker_is_draft_only_and_cancel_restores(cx: &mut TestAppC
#[gpui::test]
fn theme_and_icon_filter_pills_select_independently(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
click("open-theme-picker", &mut window);
let theme_counts = pane.read_with(&mut window, |pane, _| {
@@ -810,6 +831,7 @@ fn theme_and_icon_filter_pills_select_independently(cx: &mut TestAppContext) {
#[gpui::test]
fn theme_picker_foreground_swatches_are_local_until_apply(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
let before_palette = palette_snapshot(&mut window);
@@ -851,6 +873,7 @@ fn theme_picker_foreground_swatches_are_local_until_apply(cx: &mut TestAppContex
#[gpui::test]
fn theme_foreground_drag_hex_and_cancel_stay_local_in_a_narrow_window(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let settings_before = settings_file_snapshot();
let palette_before = palette_snapshot(&mut window);
@@ -921,6 +944,7 @@ fn theme_foreground_drag_hex_and_cancel_stay_local_in_a_narrow_window(cx: &mut T
#[gpui::test]
fn theme_picker_foreground_swatches_fit_above_footer_in_a_short_window(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (pane, mut window) = open_settings(cx);
let palette_before = palette_snapshot(&mut window);
window.simulate_resize(size(px(900.0), px(590.0)));
@@ -963,6 +987,7 @@ fn theme_picker_foreground_swatches_fit_above_footer_in_a_short_window(cx: &mut
fn theme_transfer_import_uses_a_rendered_candidate_and_keeps_unknown_fields_and_preferences(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let (import_path, fixture) = write_transfer_fixture("import");
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
@@ -1012,6 +1037,7 @@ fn theme_transfer_import_uses_a_rendered_candidate_and_keeps_unknown_fields_and_
fn theme_transfer_export_writes_a_valid_native_document_without_publishing_preferences(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let (import_path, fixture) = write_transfer_fixture("export");
let output_path = transfer_temp_path("export-output");
let (pane, mut window) = open_settings(cx);
@@ -1065,6 +1091,7 @@ fn theme_transfer_export_writes_a_valid_native_document_without_publishing_prefe
#[gpui::test]
fn theme_transfer_cancel_and_invalid_input_leave_the_editor_recoverable(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let invalid_path = transfer_temp_path("invalid");
std::fs::write(&invalid_path, b"not valid theme json").expect("write invalid theme");
let (pane, mut window) = open_settings(cx);
@@ -1106,6 +1133,7 @@ fn theme_transfer_cancel_and_invalid_input_leave_the_editor_recoverable(cx: &mut
#[gpui::test]
fn theme_transfer_late_picker_result_is_ignored_after_rendered_cancel(cx: &mut TestAppContext) {
+ let _fixture_guard = lock_theme_studio();
let (import_path, _) = write_transfer_fixture("late");
let (pane, mut window) = open_settings(cx);
let before_runtime = runtime_snapshot();
@@ -1137,6 +1165,7 @@ fn theme_transfer_late_picker_result_is_ignored_after_rendered_cancel(cx: &mut T
fn theme_picker_recommended_foreground_is_local_until_apply_and_restores_after_reopen(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let _settings_guard = SettingsBytesGuard::capture();
std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS)
.expect("write isolated picker settings");
@@ -1190,6 +1219,7 @@ fn theme_picker_recommended_foreground_is_local_until_apply_and_restores_after_r
fn theme_editor_save_only_forks_a_non_active_custom_template_without_publishing_it(
cx: &mut TestAppContext,
) {
+ let _fixture_guard = lock_theme_studio();
let _settings_guard = SettingsBytesGuard::capture();
std::fs::write(nebula_settings::settings_path(), TEST_SETTINGS)
.expect("write isolated template settings");
diff --git a/nebula_app/src/gpui_shell/ssh_settings/library.rs b/nebula_app/src/gpui_shell/ssh_settings/library.rs
index 815fda1..637c4ab 100644
--- a/nebula_app/src/gpui_shell/ssh_settings/library.rs
+++ b/nebula_app/src/gpui_shell/ssh_settings/library.rs
@@ -238,7 +238,7 @@ impl SettingsPane {
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
let font_px = cx
.try_global::()
- .map(|s| s.base_font_size_px)
+ .map(|s| s.ui_font_size_px)
.unwrap_or(15.0);
let title_h = font_px;
let subtitle_h = font_px * 0.78;
diff --git a/nebula_app/src/gpui_shell/terminal/colors.rs b/nebula_app/src/gpui_shell/terminal/colors.rs
index 1e946f6..5f9a336 100644
--- a/nebula_app/src/gpui_shell/terminal/colors.rs
+++ b/nebula_app/src/gpui_shell/terminal/colors.rs
@@ -30,7 +30,7 @@ pub struct Palette {
/// 选区叠加色。主应用默认是反色语义;本壳以半透明叠加近似,
/// 用户显式配置 `colors.selection.background` 时用不透明具体色。
pub selection: Rgba,
- /// 主题明确声明的选区文字色;普通 Nebula 主题保留原字色。
+ /// 主题或用户明确声明的选区文字色;未声明时保留原字色。
pub selection_foreground: Option,
/// ANSI 0-15。
pub ansi: [Rgba; 16],
diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs
index fda1a93..d594ba0 100644
--- a/nebula_app/src/gpui_shell/terminal/element.rs
+++ b/nebula_app/src/gpui_shell/terminal/element.rs
@@ -497,8 +497,14 @@ impl Element for TerminalElement {
.try_global::()
.map(|settings| settings.cjk_bold_regular)
.unwrap_or(true);
+ let cjk_fonts = cx
+ .try_global::()
+ .and_then(|settings| settings.font_cjk.clone());
let pick_font = |bold: bool, italic: bool, wide: bool| {
let bold = bold && !(wide && cjk_bold_regular);
+ if let Some(fonts) = cjk_fonts.as_ref().filter(|_| wide) {
+ return fonts[usize::from(bold) + 2 * usize::from(italic)].clone();
+ }
match (bold, italic) {
(false, false) => font.clone(),
(true, false) => bold_font.clone(),
diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs
index adbbd25..88ed2d9 100644
--- a/nebula_app/src/gpui_shell/terminal/keymap.rs
+++ b/nebula_app/src/gpui_shell/terminal/keymap.rs
@@ -213,7 +213,7 @@ fn kitty_sequence(ks: &Keystroke, mode: &TermMode) -> Option> {
/// and native system menu run. Other modifiers retain their terminal meaning.
pub(super) fn is_native_window_shortcut(ks: &Keystroke) -> bool {
let mods = &ks.modifiers;
- cfg!(windows)
+ crate::platform::Platform::current() == crate::platform::Platform::Windows
&& mods.alt
&& !mods.control
&& !mods.shift
@@ -347,9 +347,9 @@ mod tests {
Keystroke { modifiers: gpui::Modifiers::default(), key: key.to_owned(), key_char: None }
}
- #[cfg(windows)]
#[test]
- fn native_window_shortcuts_never_enter_a_terminal_protocol() {
+ fn native_window_shortcuts_follow_the_host_window_policy() {
+ let native = crate::platform::Platform::current() == crate::platform::Platform::Windows;
for mode in [
TermMode::empty(),
TermMode::WIN32_INPUT_MODE,
@@ -362,7 +362,17 @@ mod tests {
let mut key = Keystroke::parse(combo).unwrap();
for text in [None, Some(" ".to_owned())] {
key.key_char = text;
- assert_eq!(encode(&key, &(mode | screen)), None, "{combo}: {mode:?}");
+ if native {
+ assert_eq!(encode(&key, &(mode | screen)), None, "{combo}: {mode:?}");
+ } else {
+ assert!(!is_native_window_shortcut(&key), "{combo}");
+ if key.key == "f4" || key.key_char.is_some() {
+ assert!(
+ encode(&key, &(mode | screen)).is_some(),
+ "{combo}: {mode:?}"
+ );
+ }
+ }
}
}
}
@@ -384,8 +394,7 @@ mod tests {
"{combo}",
);
}
- #[cfg(not(windows))]
- {
+ if crate::platform::Platform::current() != crate::platform::Platform::Windows {
let mut space = Keystroke::parse("alt-space").unwrap();
space.key_char = Some(" ".to_owned());
assert_eq!(encode(&space, &TermMode::empty()), Some(b"\x1b ".to_vec()));
@@ -396,7 +405,7 @@ mod tests {
}
}
- #[cfg(all(windows, feature = "gpui-test-support"))]
+ #[cfg(feature = "gpui-test-support")]
#[gpui::test]
fn native_window_shortcuts_propagate_through_root_and_terminal(cx: &mut gpui::TestAppContext) {
use gpui::{
@@ -447,6 +456,7 @@ mod tests {
});
let probe = probe_out.unwrap();
cx.run_until_parked();
+ let native = crate::platform::Platform::current() == crate::platform::Platform::Windows;
for mode in [
TermMode::empty(),
TermMode::WIN32_INPUT_MODE,
@@ -457,16 +467,21 @@ mod tests {
for combo in ["alt-f4", "alt-space"] {
cx.update(|window, cx| {
let before = probe.read(cx).received;
+ probe.update(cx, |probe, _| probe.encoded.clear());
+ let mut keystroke = Keystroke::parse(combo).unwrap();
+ if combo == "alt-space" {
+ keystroke.key_char = Some(" ".to_owned());
+ }
let result = window.dispatch_event(
gpui::PlatformInput::KeyDown(KeyDownEvent {
- keystroke: Keystroke::parse(combo).unwrap(),
+ keystroke,
is_held: false,
prefer_character_input: false,
}),
cx,
);
- assert!(result.propagate, "Windows must receive {combo} in {mode:?}");
- assert!(probe.read(cx).encoded.is_empty());
+ assert_eq!(result.propagate, native, "{combo} in {mode:?}");
+ assert_eq!(probe.read(cx).encoded.is_empty(), native);
assert_eq!(probe.read(cx).received, before + 1);
});
}
@@ -809,7 +824,10 @@ mod tests {
for (name, codepoint) in [("space", 32), ("[", 91), ("/", 47)] {
let mut key = keystroke(name);
key.modifiers.alt = true;
- let expected = if cfg!(windows) && name == "space" {
+ let expected = if crate::platform::Platform::current()
+ == crate::platform::Platform::Windows
+ && name == "space"
+ {
None
} else {
Some(format!("\x1b[{codepoint};3u").into_bytes())
diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs
index 2560eb5..6b8eb4d 100644
--- a/nebula_app/src/gpui_shell/terminal/session.rs
+++ b/nebula_app/src/gpui_shell/terminal/session.rs
@@ -68,6 +68,27 @@ pub struct TerminalSession {
pub shell_pid: u32,
}
+#[cfg(all(test, feature = "gpui-test-support"))]
+pub(super) fn test_session()
+-> (TerminalSession, std::sync::mpsc::Receiver) {
+ let (events, _) = super::event_mailbox::channel();
+ let (stages, _) = unbounded();
+ let term = Term::new(
+ Config::default(),
+ &GridSize { columns: 80, screen_lines: 24 },
+ EventProxy { events, stages },
+ );
+ let (sender, receiver) = nebula_terminal::event_loop::EventLoopSender::standalone().unwrap();
+ (
+ TerminalSession {
+ term: Arc::new(FairMutex::new(term)),
+ notifier: Notifier(sender),
+ shell_pid: 0,
+ },
+ receiver,
+ )
+}
+
/// 一次会话 spawn 的完整出口:会话句柄 + 终端事件流 + SSH 阶段流
/// (本地会话的阶段流永远安静,接收端可以直接丢弃)。
pub type SpawnedSession = (
diff --git a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs
index 0a63d01..7d91955 100644
--- a/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs
+++ b/nebula_app/src/gpui_shell/terminal/ssh_connect_overlay.rs
@@ -292,9 +292,9 @@ pub(super) fn overlay(
// chrome 文本锚定配置字号(旧壳 ui_font 合同),不跟终端缩放。
let ui_px = cx
.try_global::()
- .map(|settings| settings.base_font_size_px)
+ .map(|settings| settings.ui_font_size_px)
.unwrap_or(15.0);
- let chrome_family = theme.mono_font_family.clone();
+ let chrome_family = theme.font_family.clone();
let log_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
let ink_strong = theme.sidebar_accent_foreground;
let ink = theme.foreground;
diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs
index 5a28191..2a6fec4 100644
--- a/nebula_app/src/gpui_shell/terminal/view.rs
+++ b/nebula_app/src/gpui_shell/terminal/view.rs
@@ -5,10 +5,14 @@ mod completion;
mod confirmation;
mod cwd_report;
mod image_paste;
+mod layout;
mod notifications;
mod path_drop;
mod pointer;
mod runtime;
+mod startup_command;
+#[cfg(all(test, feature = "gpui-test-support"))]
+mod startup_tests;
mod typography;
pub use broadcast::TerminalInput;
@@ -329,6 +333,7 @@ pub struct TerminalView {
/// 的 working/blocked 屏幕或权威 hook 前,不允许它伪造完成边沿。
agent_runtime_submit_pending: bool,
pending_runtime_submit: Option,
+ pending_shell_command: Option,
/// OSC 1337 图片的串行后台解码队列和有界像素缓存。图片不进入字符网格,
/// 只用事件携带的绝对行锚定到对应的 scrollback 位置。
pub(super) inline_images: super::inline_image::InlineImageStore,
@@ -722,6 +727,7 @@ impl TerminalView {
idle_screen_streak: 0,
agent_runtime_submit_pending: false,
pending_runtime_submit: None,
+ pending_shell_command: None,
inline_images: super::inline_image::InlineImageStore::default(),
image_paste: image_paste::ImagePasteState::default(),
path_drop: path_drop::PathDropState::default(),
@@ -811,6 +817,7 @@ impl TerminalView {
match event {
TermEvent::Wakeup => {
self.flush_pending_runtime_submit(cx);
+ self.flush_pending_shell_command(cx);
cx.notify();
},
TermEvent::MouseCursorDirty => {
@@ -975,6 +982,7 @@ impl TerminalView {
fn mark_exited(&mut self, message: String, cx: &mut Context) {
self.confirmation.invalidate();
self.pending_runtime_submit = None;
+ self.pending_shell_command = None;
self.suggest.pending_command_prompt = None;
if self.exited.is_none() {
self.exited = Some(message);
@@ -1112,184 +1120,6 @@ impl TerminalView {
.detach();
}
- /// 宿主宣告:下一次网格变化来自结构性布局改动,直接提交、不去抖。
- /// 见 `structural_resize` 字段注释。
- pub fn mark_structural_resize(&mut self) {
- self.structural_resize = true;
- }
-
- /// 元素 prepaint 回写布局:内容矩形与度量交给渲染合同裁定网格。
- /// 网格变化时同步 Term 与 ConPTY;行列不变但像素口径变化也上报 PTY
- /// (应用可能关心像素度量);稳态帧 observe 返回 None,零额外开销。
- pub fn set_layout(
- &mut self,
- origin: Point,
- cell_width: Pixels,
- line_height: Pixels,
- content: Size,
- scale: f32,
- cx: &mut Context,
- ) {
- self.origin = origin;
- self.cell_width = cell_width;
- self.line_height = line_height;
- let metrics = CellMetrics {
- cell_width: cell_width.as_f32(),
- cell_height: line_height.as_f32(),
- scale,
- };
- let change =
- self.viewports.observe(content.width.as_f32(), content.height.as_f32(), &metrics);
-
- // 启动稳定闸:开窗 resize 异步落地,首帧可能还是开窗前的旧尺寸。
- // 命中 spawn 网格前不向 Term/ConPTY 下发(PTY 出生即目标几何,
- // 零下发收口);宽限期后仍未命中(小屏收拢等真实差异)则放行,
- // 一次性按当前视口纠正。observe 会把过渡帧并进 current,因此
- // 释放判定不依赖本帧是否有增量。
- if !self.grid_synced {
- let Some(viewport) =
- change.map(|c| c.viewport).or_else(|| self.viewports.current().copied())
- else {
- return;
- };
- let landed = (viewport.cols, viewport.rows)
- == (self.window_size.num_cols, self.window_size.num_lines);
- if !landed && self.spawn_at.elapsed() < Self::STARTUP_GRID_GRACE {
- return;
- }
- self.grid_synced = true;
- self.cols = viewport.cols as usize;
- self.rows = viewport.rows as usize;
- if !landed {
- self.commit_viewport(viewport);
- } else {
- self.window_size = viewport.window_size();
- }
- return;
- }
-
- let Some(change) = change else {
- return;
- };
- let viewport = change.viewport;
- self.cols = viewport.cols as usize;
- self.rows = viewport.rows as usize;
-
- // 本地网格立刻跟手,只有子进程那一半去抖(旧壳也通过
- // `resize_active_layout_grids` 采用同一策略)。两半的代价完全不对称:客户端
- // reflow 便宜且可逆,而每一次 `ResizePseudoConsole` 都让 conhost 重排
- // 自己的缓冲区,那些重排累积出的光标行漂移事后无从察觉。让网格落后于
- // 渲染就只能靠"视觉裁剪"预览未提交的几何,而裁剪只能裁行、无法重排列
- // ——宽度一变预览就是错的,且 Term 与屏幕不一致的每一毫秒里到达的字节
- // 都会按旧宽度进网格。
- if change.grid_changed {
- self.resize_grid_only(viewport);
- }
-
- // 结构性变化(分屏创建/关闭、zoom、面板开合)不去抖:它只来一次,没有
- // 后续帧可以合并,多等的每一毫秒都是子进程按旧几何输出的窗口期。旧壳在
- // 这些路径上走 `resize_active_layout()` 同步下发,这里复刻同一条合同。
- if std::mem::take(&mut self.structural_resize) {
- self.pending_resize = None;
- self.resize_epoch = self.resize_epoch.wrapping_add(1);
- self.commit_viewport(viewport);
- return;
- }
-
- // ConPTY 的直通式 conhost 在 resize 时零输出,指望终端侧 reflow 与
- // 它内部 buffer rewrap 一致;两者的换行语义存在路径依赖差异,每多
- // 一次中间宽度的 ResizePseudoConsole 就多攒一分光标行漂移(字节取
- // 证:13 次提交后 PSReadLine 的 CUP 行比真实提示行高 7 行)。旧壳
- // (winit) 的模态拖拽天然只在松手后送达一次 resize,从不累积。这里
- // 复刻该合同:子进程那一半纯尾沿去抖——只进 pending,视口静默
- // RESIZE_SETTLE_DELAY 后一次性下发。净零手势(挤压后拖回原宽)最终
- // 提交同尺寸 no-op,rewrap 次数为零;网格已在上面逐帧跟手,所以去抖
- // 的代价只落在"子进程晚知道几十毫秒",屏幕上看不出来。
- self.pending_resize = Some(viewport);
- self.schedule_settled_resize(cx);
- }
-
- /// 只让本地网格 reflow 到 `viewport`,子进程留在旧几何上。
- ///
- /// 走 `Msg::ResizeGrid` 而不是直接锁 `Term`:event_loop 的 resize 分支会先
- /// 把旧几何下已可读的字节全部消化掉,绝对 CUP 序列因此不会被解析进新宽度
- /// 的网格。UI 线程自己上锁 resize 就绕过了这道流边界保护。
- fn resize_grid_only(&mut self, viewport: TerminalViewport) {
- let Some(session) = &self.session else { return };
- let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone());
- notifier.on_resize_grid(viewport.window_size());
- }
-
- /// Commit one viewport in grid-before-PTY order. Output produced after
- /// `ResizePseudoConsole` therefore always parses against the same geometry
- /// history ConPTY used to generate its absolute cursor coordinates.
- fn commit_viewport(&mut self, viewport: TerminalViewport) {
- let next = viewport.window_size();
- let grid_changed = (self.window_size.num_cols, self.window_size.num_lines)
- != (next.num_cols, next.num_lines);
- let pixel_changed = (self.window_size.cell_width, self.window_size.cell_height)
- != (next.cell_width, next.cell_height);
- if !grid_changed && !pixel_changed {
- return;
- }
-
- if let Some(session) = &self.session {
- let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone());
- notifier.on_resize(next);
- }
- self.window_size = next;
- }
-
- /// 一次拖拽手势(窗口边框/分屏把手)是否仍在进行。conhost 的 buffer
- /// rewrap 与本地 reflow 的换行语义存在路径依赖差异,每一次中间几何的
- /// `ResizePseudoConsole` 都会累积光标行漂移(字节取证:一次拖拽 14 次
- /// 提交后 PSReadLine 的 CUP 行比真实提示行高 7 行,且 conhost 全程零
- /// 重绘字节,漂移无法事后察觉)。旧壳 (winit) 的模态拖拽天然只在松手
- /// 后送达一次 resize,从不出这个问题;GPUI 在模态循环内持续派发布局,
- /// 时间去抖(150ms settle)与布局批次同周期,挡不住中间提交。因此按
- /// 手势门控:左键仍按住就不提交,settle 定时器自我续期到松手为止。
- #[cfg(windows)]
- fn drag_gesture_active() -> bool {
- use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON};
- // SAFETY: GetAsyncKeyState 只读全局按键状态,无副作用。
- (unsafe { GetAsyncKeyState(VK_LBUTTON as i32) } as u16 & 0x8000) != 0
- }
-
- #[cfg(not(windows))]
- fn drag_gesture_active() -> bool {
- false
- }
-
- fn schedule_settled_resize(&mut self, cx: &mut Context) {
- self.resize_epoch = self.resize_epoch.wrapping_add(1);
- let epoch = self.resize_epoch;
- let executor = cx.background_executor().clone();
- cx.spawn(async move |this, cx| {
- executor.timer(Self::RESIZE_SETTLE_DELAY).await;
- let _ = this.update(cx, |view, cx| {
- if view.resize_epoch != epoch {
- return;
- }
- let gate = Self::drag_gesture_active();
- if std::env::var_os("NEBULA_RESIZE_TRACE").is_some() {
- crate::gpui_shell::try_write_stderr(format_args!(
- "[nebula:resize-trace] settle-timer gate={gate}"
- ));
- }
- if gate {
- // 手势未松开:净零手势(挤压后拖回原宽)最终提交同尺寸
- // no-op,ConPTY 一次 rewrap 都不做。
- view.schedule_settled_resize(cx);
- return;
- }
- let Some(viewport) = view.pending_resize.take() else { return };
- view.commit_viewport(viewport);
- cx.notify();
- });
- })
- .detach();
- }
-
/// 旧壳 `change_font_size`:一步 1 逻辑 px,钳 4–64。写盘后通知宿主
/// 给所有 pane 热应用,chrome 仍锚定 toml 字号。
fn zoom_font_size(&mut self, step: f32, cx: &mut Context) {
diff --git a/nebula_app/src/gpui_shell/terminal/view/layout.rs b/nebula_app/src/gpui_shell/terminal/view/layout.rs
new file mode 100644
index 0000000..22c10a6
--- /dev/null
+++ b/nebula_app/src/gpui_shell/terminal/view/layout.rs
@@ -0,0 +1,210 @@
+//! Viewport synchronization and PTY resize lifetime.
+
+use super::*;
+
+impl TerminalView {
+ /// 宿主宣告:下一次网格变化来自结构性布局改动,直接提交、不去抖。
+ /// 见 `structural_resize` 字段注释。
+ pub fn mark_structural_resize(&mut self) {
+ self.structural_resize = true;
+ }
+
+ /// 元素 prepaint 回写布局:内容矩形与度量交给渲染合同裁定网格。
+ /// 网格变化时同步 Term 与 ConPTY;行列不变但像素口径变化也上报 PTY
+ /// (应用可能关心像素度量);稳态帧 observe 返回 None,零额外开销。
+ pub fn set_layout(
+ &mut self,
+ origin: Point,
+ cell_width: Pixels,
+ line_height: Pixels,
+ content: Size,
+ scale: f32,
+ cx: &mut Context,
+ ) {
+ self.origin = origin;
+ self.cell_width = cell_width;
+ self.line_height = line_height;
+ let metrics = CellMetrics {
+ cell_width: cell_width.as_f32(),
+ cell_height: line_height.as_f32(),
+ scale,
+ };
+ let change =
+ self.viewports.observe(content.width.as_f32(), content.height.as_f32(), &metrics);
+
+ // 启动稳定闸:开窗 resize 异步落地,首帧可能还是开窗前的旧尺寸。
+ // 命中 spawn 网格前不向 Term/ConPTY 下发(PTY 出生即目标几何,
+ // 零下发收口);宽限期后仍未命中(小屏收拢等真实差异)则放行,
+ // 一次性按当前视口纠正。observe 会把过渡帧并进 current,因此
+ // 释放判定不依赖本帧是否有增量。
+ if !self.grid_synced {
+ let Some(viewport) =
+ change.map(|c| c.viewport).or_else(|| self.viewports.current().copied())
+ else {
+ return;
+ };
+ let landed = (viewport.cols, viewport.rows)
+ == (self.window_size.num_cols, self.window_size.num_lines);
+ if !landed && self.spawn_at.elapsed() < Self::STARTUP_GRID_GRACE {
+ // A quiet shell may never produce another frame after this one.
+ // Arrange an owned deadline, keeping the latest observed geometry.
+ let first = self.pending_resize.replace(viewport).is_none();
+ if first {
+ self.schedule_startup_grid_sync(cx);
+ }
+ return;
+ }
+ self.pending_resize = None;
+ self.grid_synced = true;
+ self.cols = viewport.cols as usize;
+ self.rows = viewport.rows as usize;
+ if !landed {
+ self.commit_viewport(viewport);
+ } else {
+ self.window_size = viewport.window_size();
+ }
+ return;
+ }
+
+ let Some(change) = change else {
+ return;
+ };
+ let viewport = change.viewport;
+ self.cols = viewport.cols as usize;
+ self.rows = viewport.rows as usize;
+
+ // 本地网格立刻跟手,只有子进程那一半去抖(旧壳也通过
+ // `resize_active_layout_grids` 采用同一策略)。两半的代价完全不对称:客户端
+ // reflow 便宜且可逆,而每一次 `ResizePseudoConsole` 都让 conhost 重排
+ // 自己的缓冲区,那些重排累积出的光标行漂移事后无从察觉。让网格落后于
+ // 渲染就只能靠"视觉裁剪"预览未提交的几何,而裁剪只能裁行、无法重排列
+ // ——宽度一变预览就是错的,且 Term 与屏幕不一致的每一毫秒里到达的字节
+ // 都会按旧宽度进网格。
+ if change.grid_changed {
+ self.resize_grid_only(viewport);
+ }
+
+ // 结构性变化(分屏创建/关闭、zoom、面板开合)不去抖:它只来一次,没有
+ // 后续帧可以合并,多等的每一毫秒都是子进程按旧几何输出的窗口期。旧壳在
+ // 这些路径上走 `resize_active_layout()` 同步下发,这里复刻同一条合同。
+ if std::mem::take(&mut self.structural_resize) {
+ self.pending_resize = None;
+ self.resize_epoch = self.resize_epoch.wrapping_add(1);
+ self.commit_viewport(viewport);
+ return;
+ }
+
+ // ConPTY 的直通式 conhost 在 resize 时零输出,指望终端侧 reflow 与
+ // 它内部 buffer rewrap 一致;两者的换行语义存在路径依赖差异,每多
+ // 一次中间宽度的 ResizePseudoConsole 就多攒一分光标行漂移(字节取
+ // 证:13 次提交后 PSReadLine 的 CUP 行比真实提示行高 7 行)。旧壳
+ // (winit) 的模态拖拽天然只在松手后送达一次 resize,从不累积。这里
+ // 复刻该合同:子进程那一半纯尾沿去抖——只进 pending,视口静默
+ // RESIZE_SETTLE_DELAY 后一次性下发。净零手势(挤压后拖回原宽)最终
+ // 提交同尺寸 no-op,rewrap 次数为零;网格已在上面逐帧跟手,所以去抖
+ // 的代价只落在"子进程晚知道几十毫秒",屏幕上看不出来。
+ self.pending_resize = Some(viewport);
+ self.schedule_settled_resize(cx);
+ }
+
+ fn schedule_startup_grid_sync(&mut self, cx: &mut Context) {
+ let delay = Self::STARTUP_GRID_GRACE.saturating_sub(self.spawn_at.elapsed());
+ let executor = cx.background_executor().clone();
+ cx.spawn(async move |this, cx| {
+ executor.timer(delay).await;
+ let _ = this.update(cx, |view, cx| {
+ if view.grid_synced {
+ return;
+ }
+ let Some(viewport) = view.pending_resize.take() else { return };
+ view.grid_synced = true;
+ view.cols = viewport.cols as usize;
+ view.rows = viewport.rows as usize;
+ view.commit_viewport(viewport);
+ cx.notify();
+ });
+ })
+ .detach();
+ }
+
+ /// 只让本地网格 reflow 到 `viewport`,子进程留在旧几何上。
+ ///
+ /// 走 `Msg::ResizeGrid` 而不是直接锁 `Term`:event_loop 的 resize 分支会先
+ /// 把旧几何下已可读的字节全部消化掉,绝对 CUP 序列因此不会被解析进新宽度
+ /// 的网格。UI 线程自己上锁 resize 就绕过了这道流边界保护。
+ fn resize_grid_only(&mut self, viewport: TerminalViewport) {
+ let Some(session) = &self.session else { return };
+ let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone());
+ notifier.on_resize_grid(viewport.window_size());
+ }
+
+ /// Commit one viewport in grid-before-PTY order. Output produced after
+ /// `ResizePseudoConsole` therefore always parses against the same geometry
+ /// history ConPTY used to generate its absolute cursor coordinates.
+ fn commit_viewport(&mut self, viewport: TerminalViewport) {
+ let next = viewport.window_size();
+ let grid_changed = (self.window_size.num_cols, self.window_size.num_lines)
+ != (next.num_cols, next.num_lines);
+ let pixel_changed = (self.window_size.cell_width, self.window_size.cell_height)
+ != (next.cell_width, next.cell_height);
+ if !grid_changed && !pixel_changed {
+ return;
+ }
+
+ if let Some(session) = &self.session {
+ let mut notifier = nebula_terminal::event_loop::Notifier(session.notifier.0.clone());
+ notifier.on_resize(next);
+ }
+ self.window_size = next;
+ }
+
+ /// 一次拖拽手势(窗口边框/分屏把手)是否仍在进行。conhost 的 buffer
+ /// rewrap 与本地 reflow 的换行语义存在路径依赖差异,每一次中间几何的
+ /// `ResizePseudoConsole` 都会累积光标行漂移(字节取证:一次拖拽 14 次
+ /// 提交后 PSReadLine 的 CUP 行比真实提示行高 7 行,且 conhost 全程零
+ /// 重绘字节,漂移无法事后察觉)。旧壳 (winit) 的模态拖拽天然只在松手
+ /// 后送达一次 resize,从不出这个问题;GPUI 在模态循环内持续派发布局,
+ /// 时间去抖(150ms settle)与布局批次同周期,挡不住中间提交。因此按
+ /// 手势门控:左键仍按住就不提交,settle 定时器自我续期到松手为止。
+ #[cfg(windows)]
+ fn drag_gesture_active() -> bool {
+ use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON};
+ // SAFETY: GetAsyncKeyState 只读全局按键状态,无副作用。
+ (unsafe { GetAsyncKeyState(VK_LBUTTON as i32) } as u16 & 0x8000) != 0
+ }
+
+ #[cfg(not(windows))]
+ fn drag_gesture_active() -> bool {
+ false
+ }
+
+ fn schedule_settled_resize(&mut self, cx: &mut Context) {
+ self.resize_epoch = self.resize_epoch.wrapping_add(1);
+ let epoch = self.resize_epoch;
+ let executor = cx.background_executor().clone();
+ cx.spawn(async move |this, cx| {
+ executor.timer(Self::RESIZE_SETTLE_DELAY).await;
+ let _ = this.update(cx, |view, cx| {
+ if view.resize_epoch != epoch {
+ return;
+ }
+ let gate = Self::drag_gesture_active();
+ if std::env::var_os("NEBULA_RESIZE_TRACE").is_some() {
+ crate::gpui_shell::try_write_stderr(format_args!(
+ "[nebula:resize-trace] settle-timer gate={gate}"
+ ));
+ }
+ if gate {
+ // 手势未松开:净零手势(挤压后拖回原宽)最终提交同尺寸
+ // no-op,ConPTY 一次 rewrap 都不做。
+ view.schedule_settled_resize(cx);
+ return;
+ }
+ let Some(viewport) = view.pending_resize.take() else { return };
+ view.commit_viewport(viewport);
+ cx.notify();
+ });
+ })
+ .detach();
+ }
+}
diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs
index ad157e1..d3fe856 100644
--- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs
+++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs
@@ -114,6 +114,7 @@ impl TerminalView {
self.ssh_connect_last_step = std::time::Instant::now();
if matches!(stage, crate::ssh_session::SshStage::Failed(_)) {
self.pending_runtime_submit = None;
+ self.pending_shell_command = None;
self.command_running = false;
self.command_started = None;
self.confirmation.invalidate();
@@ -130,8 +131,8 @@ impl TerminalView {
pub(super) fn clear_foreground_agent_state(&mut self) -> bool {
self.confirmation.observe_waiting(false);
self.answers.close();
- let title_changed =
- self.running_program.take().is_some() || self.ai_session.take().is_some();
+ let program_changed = self.running_program.take().is_some();
+ let title_changed = self.ai_session.take().is_some() || program_changed;
self.invalidate_ai_session_probe();
self.primary_agent_pid = None;
self.ai_session_from_probe = false;
@@ -691,16 +692,6 @@ impl TerminalView {
crate::ai_agents::AgentKind::parse(&identity.source)?.fork_command(&identity.session_id)
}
- /// 冷恢复把快照里的 hook 身份种回 pane,右键分叉不必再等下一条事件。
- pub fn seed_ai_session(&mut self, source: String, session_id: String, cx: &mut Context) {
- if session_id.is_empty() {
- return;
- }
- self.ai_session = Some(crate::display::AiSessionIdentity { source, session_id });
- cx.emit(TerminalViewEvent::TitleChanged);
- cx.notify();
- }
-
pub(crate) fn prepare_ai_session_save(&mut self, cx: &mut Context) {
if self.ai_session_from_probe {
self.ai_session = None;
@@ -758,12 +749,15 @@ impl TerminalView {
) {
return;
}
- if let Some(session_id) = session_id {
+ if let Some(session) = session_id {
log::debug!("agent session identity from active rollout: pane={pane_id}");
view.ai_session = Some(crate::display::AiSessionIdentity {
source: "codex".to_owned(),
- session_id,
+ session_id: session.session_id,
});
+ if let Some(cwd) = session.cwd {
+ view.process_event(nebula_terminal::event::Event::CwdReport(cwd), cx);
+ }
view.ai_session_from_probe = true;
cx.emit(TerminalViewEvent::TitleChanged);
cx.notify();
@@ -773,17 +767,6 @@ impl TerminalView {
.detach();
}
- /// Queue a full shell command. Like cold resume, input may arrive before
- /// the first prompt; ConPTY preserves ordering until the shell reads it.
- pub fn run_command(&mut self, command: String, cx: &mut Context) {
- if command.is_empty() || self.exited.is_some() {
- return;
- }
- let mut bytes = command.into_bytes();
- bytes.push(b'\r');
- self.write_input(bytes, cx);
- }
-
/// 事件声明的 pane 与写管道进程的祖先链是否互相矛盾。
///
/// 三种情况都返回 `false`(放行):事件没有声明 pane(本就要回退焦点)、
@@ -1008,6 +991,7 @@ impl TerminalView {
self.flush_pending_runtime_submit(cx);
// 进程树对账必须排在下面那道早退之前:身份认不出来就早退,等于让
// 这个 pane 永远退出屏幕检测。
+ self.flush_pending_shell_command(cx);
self.reconcile_shell_activity(cx);
self.probe_missing_codex_session(cx);
let Some(session) = &self.session else { return };
diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_command.rs b/nebula_app/src/gpui_shell/terminal/view/startup_command.rs
new file mode 100644
index 0000000..b5c9266
--- /dev/null
+++ b/nebula_app/src/gpui_shell/terminal/view/startup_command.rs
@@ -0,0 +1,93 @@
+//! Resume commands wait for the new shell prompt before publishing live identity.
+
+use super::*;
+
+pub(super) struct PendingShellCommand {
+ text: String,
+ identity: Option,
+}
+
+impl TerminalView {
+ pub(crate) fn seed_restored_cwd(&mut self, cwd: String, cx: &mut Context) {
+ self.process_event(TermEvent::CwdReport(cwd), cx);
+ }
+
+ /// Keep a queued cold resume in the next snapshot without presenting it as
+ /// a live foreground agent before the shell accepts the command.
+ pub(crate) fn session_agent(&self) -> Option {
+ let pending = self.pending_shell_command.as_ref();
+ let identity =
+ pending.and_then(|request| request.identity.as_ref()).or(self.ai_session.as_ref());
+ if let Some(identity) = identity {
+ return Some(crate::session::AgentSession {
+ source: identity.source.clone(),
+ session_id: Some(identity.session_id.clone()),
+ });
+ }
+ let kind = pending
+ .and_then(|request| crate::ai_agents::AgentKind::parse_command(&request.text))
+ .or_else(|| {
+ self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse)
+ })?;
+ Some(crate::session::AgentSession { source: kind.slug().to_owned(), session_id: None })
+ }
+
+ pub fn run_command(&mut self, text: String, cx: &mut Context) {
+ if text.is_empty() || self.exited.is_some() {
+ return;
+ }
+ self.pending_shell_command = Some(PendingShellCommand { text, identity: None });
+ self.flush_pending_shell_command(cx);
+ }
+
+ pub fn seed_ai_session(&mut self, source: String, session_id: String, cx: &mut Context) {
+ if session_id.is_empty() {
+ return;
+ }
+ let identity = crate::display::AiSessionIdentity { source, session_id };
+ if let Some(pending) = self.pending_shell_command.as_mut() {
+ pending.identity = Some(identity);
+ } else if self.running_program.as_deref() == Some(identity.source.as_str()) {
+ self.ai_session = Some(identity);
+ cx.emit(TerminalViewEvent::TitleChanged);
+ cx.notify();
+ }
+ }
+
+ pub(super) fn flush_pending_shell_command(&mut self, cx: &mut Context) {
+ if self.pending_shell_command.is_none()
+ || self.pending_runtime_submit.is_some()
+ || self.exited.is_some()
+ {
+ return;
+ }
+ let Some(session) = &self.session else { return };
+ let ready = {
+ let term = session.term.lock();
+ !term.mode().intersects(TermMode::ALT_SCREEN | TermMode::VI)
+ && crate::display::nebula_shell_ready_from_raw_grid(
+ &term,
+ &self.suggest.suggest_env,
+ )
+ };
+ if !ready {
+ return;
+ }
+ let pending = self.pending_shell_command.take().expect("checked above");
+ let kind = crate::ai_agents::AgentKind::parse_command(&pending.text);
+ if let Err(error) = self.runtime_prompt(pending.text.clone(), true, cx) {
+ log::warn!("Could not submit startup command: {error:?}");
+ self.pending_shell_command = Some(pending);
+ return;
+ }
+ if let Some(kind) = kind {
+ self.running_program = Some(kind.slug().to_owned());
+ self.ai_session = pending.identity;
+ self.agent_status = crate::ai_agents::AgentStatus::Working;
+ self.agent_status_source = crate::ai_agents::AgentStatusSource::Process;
+ self.agent_turn_active = false;
+ }
+ cx.emit(TerminalViewEvent::TitleChanged);
+ cx.notify();
+ }
+}
diff --git a/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs
new file mode 100644
index 0000000..b2cd285
--- /dev/null
+++ b/nebula_app/src/gpui_shell/terminal/view/startup_tests.rs
@@ -0,0 +1,167 @@
+use super::*;
+use gpui::{Entity, TestAppContext, VisualTestContext, size};
+use gpui_component::Root;
+use nebula_terminal::event::Event;
+use std::sync::mpsc::Receiver;
+
+// Keep the terminal entity off the layout tree so tests control each viewport
+// and PTY event explicitly, while using real GPUI tasks and terminal parsing.
+struct Surface;
+impl Render for Surface {
+ fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement {
+ div().size_full()
+ }
+}
+
+fn open(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext, Receiver) {
+ cx.update(|cx| {
+ gpui_component::init(cx);
+ cx.set_global(Settings::load(nebula_settings::ThemeName::Nord));
+ });
+ let mut result = None;
+ let (_, window) = cx.add_window_view(|window, cx| {
+ let view = cx.new(|cx| {
+ TerminalView::new(
+ 42,
+ (80, 24),
+ TerminalLaunch::Local {
+ cwd: None,
+ shell: Some(nebula_terminal::tty::Shell::new(
+ "pebrel-test-missing-shell-executable".into(),
+ vec![],
+ )),
+ shell_name: None,
+ },
+ window,
+ cx,
+ )
+ });
+ let receiver = view.update(cx, |view, _| {
+ let (session, receiver) = session::test_session();
+ view.session = Some(session);
+ view.error = None;
+ view.exited = None;
+ view.exec_context = None;
+ view.suggest.suggest_env = crate::display::SuggestEnv::Wsl { distro: "Debian".into() };
+ receiver
+ });
+ result = Some((view, receiver));
+ Root::new(cx.new(|_| Surface), window, cx)
+ });
+ let (view, receiver) = result.unwrap();
+ (view, window, receiver)
+}
+
+fn feed(view: &mut TerminalView, bytes: &[u8]) {
+ let mut term = view.session.as_ref().unwrap().term.lock();
+ let mut parser = nebula_terminal::vte::ansi::Processor::<
+ nebula_terminal::vte::ansi::StdSyncHandler,
+ >::default();
+ parser.advance(&mut *term, bytes);
+}
+
+#[gpui::test]
+fn review_regression_cold_resume_survives_initial_prompt_and_clears_on_exit(
+ cx: &mut TestAppContext,
+) {
+ let (view, window, receiver) = open(cx);
+ window.update(|_, cx| view.update(cx, |view, cx| {
+ view.run_command("codex resume saved-42".into(), cx);
+ view.seed_ai_session("codex".into(), "saved-42".into(), cx);
+ assert!(receiver.try_recv().is_err(), "do not submit into shell initialization");
+ assert_eq!(view.session_agent().unwrap().session_id.as_deref(), Some("saved-42"));
+ view.process_event(Event::CommandDone { exit_code: Some(0) }, cx);
+ assert!(view.pending_shell_command.is_some());
+ feed(view, b"\x1b]133;A\x07hello@host:/home/hello$ ");
+ view.process_event(Event::Wakeup, cx);
+ assert!(view.pending_shell_command.is_none());
+ assert_eq!(view.running_program.as_deref(), Some("codex"));
+ assert_eq!(view.ai_session.as_ref().unwrap().session_id, "saved-42");
+ assert!(matches!(receiver.try_recv().unwrap(), Msg::Input(bytes) if bytes.as_ref() == b"codex resume saved-42"));
+ // The initial shell edge cannot consume the pending Enter or identity.
+ view.process_event(Event::CommandDone { exit_code: Some(0) }, cx);
+ assert!(view.ai_session.is_some());
+ feed(view, b"codex resume saved-42");
+ view.flush_pending_runtime_submit(cx);
+ assert!(matches!(receiver.try_recv().unwrap(), Msg::Input(bytes) if bytes.as_ref() == b"\r"));
+ view.process_event(Event::CommandStart, cx);
+ assert_eq!(view.runtime_agent().unwrap().kind, "codex");
+ assert_eq!(view.ai_session.as_ref().unwrap().session_id, "saved-42");
+ view.process_event(Event::CommandDone { exit_code: Some(0) }, cx);
+ assert!(view.running_program.is_none());
+ assert!(view.ai_session.is_none(), "both foreground fields must clear together");
+ assert!(view.runtime_agent().is_none());
+ }));
+}
+
+#[gpui::test]
+fn review_regression_saved_identity_without_a_resume_command_is_not_live(cx: &mut TestAppContext) {
+ let (view, window, _) = open(cx);
+ window.update(|_, cx| {
+ view.update(cx, |view, cx| {
+ view.seed_ai_session("codex".into(), "saved-42".into(), cx);
+ assert!(view.ai_session.is_none());
+ view.run_command("codex resume saved-42".into(), cx);
+ feed(view, b"Password: ");
+ view.process_event(Event::Wakeup, cx);
+ assert!(
+ view.pending_shell_command.is_some(),
+ "never send a command into authentication"
+ );
+ assert!(view.running_program.is_none());
+ })
+ });
+}
+
+#[gpui::test]
+fn review_regression_quiet_startup_and_maximize_deliver_the_latest_pty_size(
+ cx: &mut TestAppContext,
+) {
+ let (view, window, receiver) = open(cx);
+ window.update(|_, cx| {
+ view.update(cx, |view, cx| {
+ view.set_layout(
+ point(px(0.0), px(0.0)),
+ px(10.0),
+ px(20.0),
+ size(px(1200.0), px(700.0)),
+ 1.0,
+ cx,
+ );
+ view.set_layout(
+ point(px(0.0), px(0.0)),
+ px(10.0),
+ px(20.0),
+ size(px(1600.0), px(900.0)),
+ 1.0,
+ cx,
+ );
+ assert!(receiver.try_recv().is_err(), "startup waits for final layout");
+ })
+ });
+ window.run_until_parked();
+ window.executor().advance_clock(TerminalView::STARTUP_GRID_GRACE);
+ window.run_until_parked();
+ let sizes: Vec<_> = receiver
+ .try_iter()
+ .filter_map(|message| match message {
+ Msg::Resize(size) => Some((size.num_cols, size.num_lines)),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(sizes, [(160, 45)], "one final resize even without a new terminal frame");
+ window.update(|_, cx| {
+ view.update(cx, |view, cx| {
+ view.mark_structural_resize();
+ view.set_layout(
+ point(px(0.0), px(0.0)),
+ px(10.0),
+ px(20.0),
+ size(px(800.0), px(500.0)),
+ 1.0,
+ cx,
+ );
+ })
+ });
+ assert!(receiver.try_iter().any(|message| matches!(message, Msg::Resize(size) if size.num_cols == 80 && size.num_lines == 25)));
+}
diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs
index 5568e2b..429fd14 100644
--- a/nebula_app/src/gpui_shell/theme.rs
+++ b/nebula_app/src/gpui_shell/theme.rs
@@ -9,6 +9,8 @@ use nebula_settings::ThemeName;
mod custom;
mod syntax;
+#[cfg(all(test, feature = "gpui-test-support"))]
+mod selection_tests;
pub(crate) use custom::ResolvedTheme;
struct DocumentColors {
@@ -598,6 +600,10 @@ fn soften(c: crate::display::color::Rgb, keep: f32) -> crate::display::color::Rg
}
fn apply_skin_tokens(chrome: &ResolvedTheme, cx: &mut App) {
+ let (ui_font_family, ui_font_size) = cx
+ .try_global::()
+ .map(|settings| (settings.ui_font_family.clone(), settings.ui_font_size_override))
+ .unwrap_or_default();
let sk = chrome.skin();
let transparent = hsla(0.0, 0.0, 0.0, 0.0);
let theme = Theme::global_mut(cx);
@@ -698,7 +704,10 @@ fn apply_skin_tokens(chrome: &ResolvedTheme, cx: &mut App) {
// 焦点 / 选择 / 链接 / 拖拽。
theme.ring = ink(sk.accent);
theme.caret = ink(sk.accent);
- theme.selection = wash(sk.accent_soft);
+ // TextView paints selection over glyphs. Match gpui-component's 0.3 alpha
+ // cap instead of passing through the opaque selected surface from Skin.
+ let selection = wash(sk.accent_soft);
+ theme.selection = selection.alpha(selection.a.min(0.3));
theme.link = ink(sk.accent);
theme.link_hover = shift3(sk.accent.r, sk.accent.g, sk.accent.b, 0.10);
theme.link_active = shift3(sk.accent.r, sk.accent.g, sk.accent.b, 0.18);
@@ -717,8 +726,8 @@ fn apply_skin_tokens(chrome: &ResolvedTheme, cx: &mut App) {
// 字号与圆角:控件 pill 档 = 旧壳 UI_CORNER_RADIUS_LOGICAL(8);浮层
// 12,低于终端卡的 14——三档呼应旧壳的圆角层级。
- theme.font_size = px(14.0);
- theme.mono_font_size = px(13.0);
+ theme.font_size = px(ui_font_size.unwrap_or(14.0));
+ theme.mono_font_size = theme.font_size * (13.0 / 14.0);
// 整壳的兜底字体(fork `root.rs` 用 `theme.font_family` 给根容器)。上游
// 默认 `.SystemUIFont` 在 Windows 上没落到 UI 字体,中文最终回落进终端等
@@ -728,14 +737,17 @@ fn apply_skin_tokens(chrome: &ResolvedTheme, cx: &mut App) {
// 我们是终端,所以等宽在这里是**语义标记**而不是全局字体:路径、键帽、
// 命令、数值这类"机器读、要逐字符对齐、要能整段复制"的东西显式走 mono;
// 标题和说明是给人读的,走 sans。
- #[cfg(target_os = "windows")]
- {
- theme.font_family = "Microsoft YaHei UI".into();
- // UI 中的等宽语义也必须稳定。终端字体由 TerminalView 单独读取;
- // 若把用户字体组写进全局 theme,tab、标题和代码字面量的字宽都会
- // 随终端主字体变化,进而破坏 chrome 的既定间距。
- theme.mono_font_family = crate::font_install::REQUIRED_FONT_FAMILY.into();
- }
+ let default_ui_font =
+ if crate::platform::Platform::current() == crate::platform::Platform::Windows {
+ // UI 中的等宽语义也必须稳定。终端字体由 TerminalView 单独读取;
+ // 若把用户字体组写进全局 theme,tab、标题和代码字面量的字宽都会
+ // 随终端主字体变化,进而破坏 chrome 的既定间距。
+ theme.mono_font_family = crate::font_install::REQUIRED_FONT_FAMILY.into();
+ "Microsoft YaHei UI"
+ } else {
+ ".SystemUIFont"
+ };
+ theme.font_family = ui_font_family.unwrap_or_else(|| default_ui_font.to_owned()).into();
theme.radius = px(crate::display::UI_CORNER_RADIUS_LOGICAL);
theme.radius_lg = px(12.0);
diff --git a/nebula_app/src/gpui_shell/theme/selection_tests.rs b/nebula_app/src/gpui_shell/theme/selection_tests.rs
new file mode 100644
index 0000000..3d538fa
--- /dev/null
+++ b/nebula_app/src/gpui_shell/theme/selection_tests.rs
@@ -0,0 +1,230 @@
+use gpui::{
+ App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, Modifiers,
+ MouseButton, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _,
+ TestAppContext, Window, div, point, px,
+};
+use gpui_component::{
+ ActiveTheme as _, Root, Theme, ThemeMode, WindowExt as _,
+ text::{TextView, TextViewState},
+};
+use nebula_settings::ThemeName;
+
+use super::{ResolvedTheme, apply_skin_tokens, chrome_theme, wash};
+
+fn apply_reader_theme(name: ThemeName, cx: &mut App) {
+ let chrome = ResolvedTheme::builtin(name, None);
+ let mode = if chrome.skin().is_light { ThemeMode::Light } else { ThemeMode::Dark };
+ Theme::change(mode, None, cx);
+ apply_skin_tokens(&chrome, cx);
+ // Match the product shell at full opacity without loading wallpaper,
+ // preferences, or tray integrations into the isolated test process.
+ let background = super::shell_color(chrome.chrome_palette());
+ let theme = Theme::global_mut(cx);
+ theme.background = background;
+ theme.tokens.background = background.into();
+}
+
+#[gpui::test]
+fn text_selection_theme_preserves_rgb_and_caps_overlay_alpha(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ gpui_component::init(cx);
+ for name in ThemeName::BUILTIN {
+ let chrome = chrome_theme(name);
+ let original = wash(chrome.skin().accent_soft);
+ apply_reader_theme(name, cx);
+
+ let theme = cx.theme();
+ let selection = theme.selection;
+ assert_eq!(
+ (selection.h, selection.s, selection.l),
+ (original.h, original.s, original.l)
+ );
+ assert_eq!(selection.a, original.a.min(0.3), "{name:?}");
+ assert!(selection.a > 0.0, "{name:?}");
+ assert_eq!(theme.tokens.selection.color, selection, "{name:?}");
+ assert_eq!(theme.tokens.selection.background, selection.into(), "{name:?}");
+ // Solid selection surfaces for lists are not text overlays.
+ assert_eq!(theme.list_active, original, "{name:?}");
+ }
+ });
+}
+
+const SELECTED_TEXT: &str = "alpha beta gamma delta";
+
+struct ReaderSelectionFixture {
+ text: Entity,
+ width: f32,
+}
+
+impl Render for ReaderSelectionFixture {
+ fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement {
+ div()
+ .id("reader-selection-fixture")
+ .debug_selector(|| "reader-selection-fixture".to_owned())
+ .w(px(self.width))
+ .h(px(180.0))
+ .text_size(px(16.0))
+ .text_color(cx.theme().foreground)
+ .bg(cx.theme().background)
+ .child(TextView::new(&self.text).selectable(true))
+ }
+}
+
+#[gpui::test]
+fn reader_drag_selection_keeps_text_and_copy_after_theme_switch(cx: &mut TestAppContext) {
+ cx.update(gpui_component::init);
+ let mut fixture = None;
+ let (_, cx) = cx.add_window_view(|window, cx| {
+ let view = cx.new(|cx| ReaderSelectionFixture {
+ text: cx.new(|cx| TextViewState::markdown(SELECTED_TEXT, cx)),
+ width: 480.0,
+ });
+ fixture = Some(view.clone());
+ Root::new(view, window, cx)
+ });
+ let fixture = fixture.unwrap();
+
+ for (name, width) in [(ThemeName::MintLight, 480.0), (ThemeName::Nord, 160.0)] {
+ cx.update(|window, cx| {
+ window.clear_text_selection(cx);
+ apply_reader_theme(name, cx);
+ fixture.update(cx, |view, cx| {
+ view.width = width;
+ cx.notify();
+ });
+ let _ = window.draw(cx);
+ });
+ cx.run_until_parked();
+ let bounds = cx.debug_bounds("reader-selection-fixture").unwrap();
+ let start = bounds.origin + point(px(1.0), px(10.0));
+ let end = bounds.bottom_right() - point(px(2.0), px(2.0));
+ cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
+ cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
+ cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
+ cx.update(|window, cx| {
+ let _ = window.draw(cx);
+ assert_eq!(window.selected_text(cx).trim(), SELECTED_TEXT, "{name:?}");
+ assert!(cx.theme().selection.a <= 0.3, "{name:?}");
+ });
+ assert_eq!(cx.debug_bounds("reader-selection-fixture").unwrap(), bounds);
+ let copy = if crate::platform::Platform::current() == crate::platform::Platform::MacOS {
+ "cmd-c"
+ } else {
+ "ctrl-c"
+ };
+ cx.simulate_keystrokes(copy);
+ let copied = cx.read_from_clipboard().and_then(|item| item.text()).unwrap();
+ assert_eq!(copied.trim(), SELECTED_TEXT, "{name:?}");
+ }
+}
+
+/// Native visual check for the same TextView used by the answer reader. Input
+/// events are dispatched to this test window, never to another desktop window.
+/// The probe does not touch the OS clipboard or start a terminal/AI session.
+#[test]
+#[ignore = "requires a Windows desktop and PEBREL_SELECTION_QA_DIR for screenshots"]
+fn native_reader_selection_preview() {
+ assert_eq!(
+ crate::platform::Platform::current(),
+ crate::platform::Platform::Windows,
+ "this native desktop probe requires Windows",
+ );
+ use gpui::{
+ Bounds, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PlatformInput, WindowBounds,
+ WindowOptions, size,
+ };
+ use std::{path::PathBuf, time::Duration};
+
+ let output = PathBuf::from(
+ std::env::var_os("PEBREL_SELECTION_QA_DIR").expect("set QA output directory"),
+ );
+ let name = std::env::var("PEBREL_SELECTION_QA_THEME").unwrap_or_else(|_| "MintLight".into());
+ let name = ThemeName::from_prompt_name(&name).expect("a built-in theme name");
+ let width: f32 = std::env::var("PEBREL_SELECTION_QA_WIDTH")
+ .unwrap_or_else(|_| "480".into())
+ .parse()
+ .expect("numeric width");
+ std::fs::create_dir_all(&output).unwrap();
+ let ready = output.join("ready.json");
+ assert!(!ready.exists(), "use a fresh QA directory");
+ let ready_after_run = ready.clone();
+
+ gpui_platform::application().run(move |cx| {
+ gpui_component::init(cx);
+ apply_reader_theme(name, cx);
+ let selection = cx.theme().selection;
+ let window = cx
+ .open_window(
+ WindowOptions {
+ window_bounds: Some(WindowBounds::Windowed(Bounds::new(
+ point(px(40.0), px(60.0)),
+ size(px(680.0), px(260.0)),
+ ))),
+ focus: false,
+ show: true,
+ ..Default::default()
+ },
+ |window, cx| {
+ let view = cx.new(|cx| ReaderSelectionFixture {
+ text: cx.new(|cx| TextViewState::markdown(SELECTED_TEXT, cx)),
+ width,
+ });
+ cx.new(|cx| Root::new(view, window, cx))
+ },
+ )
+ .unwrap();
+ cx.spawn(async move |cx| {
+ cx.background_executor().timer(Duration::from_millis(500)).await;
+ let selected = cx
+ .update_window(window.into(), |_, window, cx| {
+ let start = point(px(1.0), px(10.0));
+ let end = point(px(width - 2.0), px(178.0));
+ window.dispatch_event(
+ PlatformInput::MouseDown(MouseDownEvent {
+ position: start,
+ button: MouseButton::Left,
+ modifiers: Modifiers::default(),
+ click_count: 1,
+ first_mouse: false,
+ }),
+ cx,
+ );
+ window.dispatch_event(
+ PlatformInput::MouseMove(MouseMoveEvent {
+ position: end,
+ pressed_button: Some(MouseButton::Left),
+ modifiers: Modifiers::default(),
+ }),
+ cx,
+ );
+ window.dispatch_event(
+ PlatformInput::MouseUp(MouseUpEvent {
+ position: end,
+ button: MouseButton::Left,
+ modifiers: Modifiers::default(),
+ click_count: 1,
+ }),
+ cx,
+ );
+ let _ = window.draw(cx);
+ window.selected_text(cx)
+ })
+ .unwrap();
+ assert_eq!(selected.trim(), SELECTED_TEXT);
+ cx.background_executor().timer(Duration::from_millis(250)).await;
+ std::fs::write(&ready, serde_json::to_vec(&serde_json::json!({
+ "pid": std::process::id(), "theme": name.prompt_name(),
+ "width": width, "selected_text": selected.trim(), "selection_alpha": selection.a,
+ })).unwrap()).unwrap();
+ for _ in 0..120 {
+ if output.join("capture-complete").exists() {
+ break;
+ }
+ cx.background_executor().timer(Duration::from_millis(500)).await;
+ }
+ cx.update(|cx| cx.quit());
+ })
+ .detach();
+ });
+ assert!(ready_after_run.exists(), "native probe did not reach selected state");
+}
diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs
index be6cd4a..f85d429 100644
--- a/nebula_app/src/gpui_shell/workspace.rs
+++ b/nebula_app/src/gpui_shell/workspace.rs
@@ -773,7 +773,7 @@ pub struct NebulaWorkspace {
_command_manager_subscription: Subscription,
/// Git/SVN 提交信息输入(GPUI 输入组件);提交动作直达共享模型
/// `vcs_commit_message`,不经旧壳的内部输入状态机。
- git_commit_input: Entity,
+ git_commit_input: vcs_panel::CommitInput,
/// Git 树"丢弃改动"的二次确认(路径);任何其他 VCS 操作都清掉它。
vcs_discard_confirm: Option,
/// 命令面板的行覆盖:`None` = 常规命令目录,`Some` = 某个专用列表
@@ -953,7 +953,7 @@ impl NebulaWorkspace {
workspace_ui_language().pick("搜索文件和文件夹…", "Search files and folders..."),
)
});
- let git_commit_input = cx.new(|cx| InputState::new(window, cx).placeholder("提交信息…"));
+ let git_commit_input = vcs_panel::CommitInput::new(window, cx);
let command_palette_subscription = cx.subscribe_in(
&command_palette_input,
window,
@@ -1109,7 +1109,7 @@ impl NebulaWorkspace {
if !runtime.restore_session
|| !this.try_restore_session(runtime.resume_ai, window, cx)
{
- this.add_terminal(window, cx);
+ this.add_terminal_at(std::env::current_dir().ok(), None, window, cx);
}
},
windowing::WorkspaceStartup::NewTerminal { cwd } => {
@@ -1908,24 +1908,31 @@ impl NebulaWorkspace {
let mut panes: Vec = Vec::new();
for (index, leaf) in layout.leaves().into_iter().enumerate() {
let LayoutSession::Pane { cwd, agent } = leaf else { continue };
- let launch = if index == 0 {
- Self::terminal_launch_from_session(&saved_launch, crate::session::valid_dir(cwd))
- } else {
- // 共享 v4 与旧壳只把 Tab 的 launch 赋给首 Pane;其它叶子没有
- // 独立启动身份,保持既有 Default 恢复语义。
- crate::gpui_shell::terminal::view::TerminalLaunch::Local {
- cwd: crate::session::valid_dir(cwd),
- shell: None,
- shell_name: None,
- }
- };
+ let mut launch_session =
+ if index == 0 { saved_launch.clone() } else { Self::configured_local_launch(cx) };
+ if matches!(launch_session, LaunchSession::Default) {
+ launch_session = Self::configured_local_launch(cx);
+ }
+ let guest_directory =
+ tab_duplication::inherit_guest_directory(&mut launch_session, cwd);
+ let local_cwd = if guest_directory { None } else { crate::session::valid_dir(cwd) };
+ let launch = Self::terminal_launch_from_session(&launch_session, local_cwd);
let command = restored_agent_command(resume_ai, agent.as_ref());
+ let resuming = command.is_some();
let pane = self.new_pane(grid, launch, command, window, cx);
+ // Display and persist the restored location while the guest shell
+ // starts; subsequent OSC reports remain authoritative.
+ if guest_directory {
+ pane.view.update(cx, |view, cx| {
+ view.seed_restored_cwd(cwd.clone(), cx);
+ });
+ }
// 冷恢复已经知道这段对话的 hook 身份:种回 view,右键「分叉
// AI 会话」不必再等下一条带 session_id 的 hook。
- if let Some((source, session_id)) = agent
- .as_ref()
- .and_then(|agent| Some((agent.source.clone(), agent.session_id.clone()?)))
+ if resuming
+ && let Some((source, session_id)) = agent
+ .as_ref()
+ .and_then(|agent| Some((agent.source.clone(), agent.session_id.clone()?)))
{
pane.view.update(cx, |view, cx| view.seed_ai_session(source, session_id, cx));
}
@@ -1975,22 +1982,7 @@ impl NebulaWorkspace {
return (String::new(), None);
};
let view = pane.view.read(cx);
- let agent = view
- .ai_session
- .as_ref()
- .map(|identity| AgentSession {
- source: identity.source.clone(),
- session_id: Some(identity.session_id.clone()),
- })
- .or_else(|| {
- view.running_program
- .as_deref()
- .filter(|program| crate::ai_agents::AgentKind::parse(program).is_some())
- .map(|program| AgentSession {
- source: program.to_owned(),
- session_id: None,
- })
- });
+ let agent = view.session_agent();
(view.cwd.clone(), agent)
};
let layout = crate::gpui_shell::session_restore::layout_from_tree(tree, &leaf_data);
@@ -2402,12 +2394,12 @@ impl NebulaWorkspace {
/// 提交按钮/Enter:读 GPUI 输入框的消息直达共享模型(git 提交暂存区、
/// svn 提交工作副本),成功入队后清空输入。
fn submit_vcs_commit(&mut self, window: &mut Window, cx: &mut Context) {
- let message = self.git_commit_input.read(cx).value().trim().to_string();
+ let message = self.git_commit_input.input.read(cx).value().trim().to_string();
if message.is_empty() {
return;
}
self.side_panel.vcs_commit_message(&message);
- self.git_commit_input.update(cx, |input, cx| input.set_value("", window, cx));
+ self.git_commit_input.input.update(cx, |input, cx| input.set_value("", window, cx));
cx.notify();
}
@@ -3012,7 +3004,7 @@ impl NebulaWorkspace {
// 页签——用户想看的永远是"当前这台机器上的文件"。
crate::display::side_panel::PanelView::Files if remote => self.render_remote_files(cx),
crate::display::side_panel::PanelView::Files => self.render_file_tree(cx),
- crate::display::side_panel::PanelView::Git => self.render_git_tree(cx),
+ crate::display::side_panel::PanelView::Git => self.render_git_tree(window, cx),
};
div()
.h_full()
diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs
index ee1c9ff..ea12f94 100644
--- a/nebula_app/src/gpui_shell/workspace/file_tree.rs
+++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs
@@ -3,6 +3,7 @@
//! 几何与密度以旧 OpenGL 壳的 `display::side_panel::panel_layout`
//! (side_panel.rs:1695)和它的行水洗(同文件 2712-2800)为基准,不自创数字。
+use crate::display::side_panel::PanelNotice;
use std::ops::Range;
use std::path::PathBuf;
@@ -536,7 +537,7 @@ impl NebulaWorkspace {
})),
),
)
- .when_some(self.side_panel.root_notice(), |panel, notice| {
+ .when_some(self.side_panel.localized_root_notice(crate::gpui_shell::config::ui_language(cx)), |panel, notice| {
panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned()))
})
.child(search_box)
@@ -773,7 +774,7 @@ impl NebulaWorkspace {
cx: &mut Context,
) {
if !path.exists() {
- self.side_panel.set_notice("路径已不存在".to_owned());
+ self.side_panel.set_notice(PanelNotice::PathUnavailable);
cx.notify();
return;
}
@@ -809,7 +810,7 @@ impl NebulaWorkspace {
this.side_panel.request_refresh();
this.sync_side_panel_to_active(false, cx);
},
- Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")),
+ Err(error) => this.side_panel.set_notice(PanelNotice::DeleteFailed(error)),
}
cx.notify();
});
@@ -835,22 +836,16 @@ impl NebulaWorkspace {
let result = task.await;
let _ = this.update(cx, |this, cx| {
use crate::display::side_panel::IgnoreOutcome;
- use crate::i18n::Message;
- let language = crate::gpui_shell::config::ui_language(cx);
let notice = match result {
- Ok(IgnoreOutcome::Added { entry, .. }) => {
- language.format(Message::FilesIgnoreAdded, &[("entry", &entry)])
- },
- Ok(IgnoreOutcome::Removed { entry, .. }) => {
- language.format(Message::FilesIgnoreRemoved, &[("entry", &entry)])
- },
+ Ok(IgnoreOutcome::Added { entry, .. }) => PanelNotice::IgnoreAdded(entry),
+ Ok(IgnoreOutcome::Removed { entry, .. }) => PanelNotice::IgnoreRemoved(entry),
Ok(IgnoreOutcome::AlreadyPresent { entry }) => {
- language.format(Message::FilesIgnorePresent, &[("entry", &entry)])
+ PanelNotice::IgnorePresent(entry)
},
Ok(IgnoreOutcome::AlreadyVisible { entry }) => {
- language.format(Message::FilesIgnoreVisible, &[("entry", &entry)])
+ PanelNotice::IgnoreVisible(entry)
},
- Err(error) => language.format(Message::FilesIgnoreFailed, &[("error", &error)]),
+ Err(error) => PanelNotice::IgnoreFailed(error),
};
this.side_panel.set_notice(notice);
this.side_panel.request_refresh();
diff --git a/nebula_app/src/gpui_shell/workspace/pane_header.rs b/nebula_app/src/gpui_shell/workspace/pane_header.rs
index 473c7fe..f5a7c6e 100644
--- a/nebula_app/src/gpui_shell/workspace/pane_header.rs
+++ b/nebula_app/src/gpui_shell/workspace/pane_header.rs
@@ -260,7 +260,7 @@ impl NebulaWorkspace {
let settings = cx.try_global::();
let chrome_family = theme.mono_font_family.clone();
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
- let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0);
+ let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0);
let title_px = label_px * 0.78;
let PaneTitle { logo, glyph, text } = self.pane_title(view, cx, dark);
let group: SharedString = format!("pane-header-{pane_id}").into();
diff --git a/nebula_app/src/gpui_shell/workspace/session_persistence.rs b/nebula_app/src/gpui_shell/workspace/session_persistence.rs
index 581b9bd..b1d0463 100644
--- a/nebula_app/src/gpui_shell/workspace/session_persistence.rs
+++ b/nebula_app/src/gpui_shell/workspace/session_persistence.rs
@@ -119,6 +119,12 @@ mod tests {
}
use crate::session::{AgentSession, LayoutSession, TabSession};
+ fn ordinary_window() -> SessionPersistence {
+ // Hosted Windows runners can be elevated. These tests exercise ordinary
+ // window persistence; privileged isolation has its own negative test.
+ SessionPersistence { isolated: false, ..SessionPersistence::default() }
+ }
+
fn sample_session() -> Session {
let mut tab = TabSession::single("D:/work".into(), Some("Workspace".into()), None);
tab.layout = Some(LayoutSession::Pane {
@@ -145,7 +151,7 @@ mod tests {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("session.json");
let expected = sample_session();
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
save_to(&mut state, &path, Some(expected.clone()), SaveReason::WindowClose);
save_to(&mut state, &path, None, SaveReason::Checkpoint);
save_to(&mut state, &path, None, SaveReason::Quit);
@@ -160,7 +166,7 @@ mod tests {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("session.json");
let expected = sample_session();
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
save_to(&mut state, &path, Some(expected.clone()), SaveReason::Checkpoint);
drop(state);
let restored = crate::session::load_from(&path).unwrap();
@@ -171,7 +177,7 @@ mod tests {
#[test]
fn teardown_events_cannot_overwrite_the_final_snapshot() {
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
let expected = sample_session();
state.save_with(Some(expected.clone()), SaveReason::Quit, |_| Ok(()));
for reason in [SaveReason::Checkpoint, SaveReason::TabsClosed, SaveReason::WindowClose] {
@@ -187,7 +193,7 @@ mod tests {
#[test]
fn explicitly_closing_all_tabs_does_not_resurrect_them() {
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| Ok(()));
state.save_with(Some(Session::new(0, vec![])), SaveReason::TabsClosed, |_| Ok(()));
state.save_with(None, SaveReason::Quit, |_| Ok(()));
@@ -198,7 +204,7 @@ mod tests {
#[test]
fn an_empty_startup_or_auxiliary_window_cannot_erase_a_saved_session() {
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
for current in [None, Some(Session::new(0, vec![]))] {
state.save_with(current, SaveReason::Checkpoint, |_| {
panic!("initial empty state must not reach storage")
@@ -209,7 +215,7 @@ mod tests {
#[test]
fn failed_checkpoint_and_final_writes_are_retried() {
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
let session = sample_session();
let fail = |_: &Session| Err(std::io::Error::other("storage unavailable"));
state.save_with(Some(session.clone()), SaveReason::Checkpoint, fail);
@@ -225,7 +231,7 @@ mod tests {
#[test]
fn unchanged_checkpoints_do_not_rewrite_storage() {
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| Ok(()));
state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| {
panic!("unchanged checkpoint must not write")
@@ -234,7 +240,7 @@ mod tests {
#[test]
fn failed_window_close_is_retried_even_without_remaining_windows() {
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
state.save_with(Some(sample_session()), SaveReason::WindowClose, |_| {
Err(std::io::Error::other("temporary write failure"))
});
@@ -248,7 +254,7 @@ mod tests {
#[test]
fn failed_explicit_empty_snapshot_is_retried_without_resurrecting_tabs() {
- let mut state = SessionPersistence::default();
+ let mut state = ordinary_window();
state.save_with(Some(sample_session()), SaveReason::Checkpoint, |_| Ok(()));
state.save_with(Some(Session::new(0, vec![])), SaveReason::WindowClose, |_| {
Err(std::io::Error::other("temporary write failure"))
diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs
index 4b114c7..6b1c60a 100644
--- a/nebula_app/src/gpui_shell/workspace/sidebar.rs
+++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs
@@ -53,14 +53,46 @@ pub(super) fn resting_activity(
}
impl NebulaWorkspace {
- /// 侧栏标签的保守字宽:塑形一个 "M" 取 advance,供旧壳列数截断逻辑
- /// 估算。必须使用 UI 字体;终端字体变化不属于 chrome 的布局输入。
- fn sidebar_cell_width(&self, window: &mut Window, family: &SharedString, size_px: f32) -> f32 {
- let shaped = window.text_system().shape_line(
- SharedString::new_static("M"),
+ pub(super) fn shell_status_label(
+ tag: SharedString,
+ family: SharedString,
+ size_px: f32,
+ color: gpui::Hsla,
+ ) -> gpui::Div {
+ // As in 1.7, short labels keep their intrinsic width and align right in
+ // the status row. Only a long label is clipped to the available space.
+ div()
+ .max_w_full()
+ .min_w_0()
+ .truncate()
+ .font_family(family)
+ .text_size(px(size_px))
+ .font_weight(FontWeight::NORMAL)
+ .text_color(color)
+ .child(tag)
+ }
+
+ pub(super) fn tab_status_slot(width: f32) -> gpui::Div {
+ // Nerd Font ink can extend beyond its advance. Keep the 1.7 slot
+ // unclipped; the enclosing tab still clips at its own outer boundary.
+ div().relative().w(px(width)).h_full().flex_shrink_0()
+ }
+
+ /// Reserve the shaped shell label's width, capped to leave room for the title.
+ pub(super) fn shell_status_width(
+ window: &Window,
+ tag: Option<&SharedString>,
+ family: &SharedString,
+ size_px: f32,
+ minimum: f32,
+ maximum: f32,
+ ) -> f32 {
+ let Some(tag) = tag else { return minimum };
+ let line = window.text_system().shape_line(
+ tag.clone(),
px(size_px),
&[gpui::TextRun {
- len: 1,
+ len: tag.len(),
font: gpui::font(family.clone()),
color: gpui::Hsla::default(),
background_color: None,
@@ -69,8 +101,7 @@ impl NebulaWorkspace {
}],
None,
);
- let width = f32::from(shaped.width);
- if width > 0.5 { width } else { size_px * 0.6 }
+ f32::from(line.width).ceil().clamp(minimum, maximum.max(minimum))
}
/// 旧壳 `icons::push_spinner` 的 canvas 复刻:暗轨道 + 绕行亮弧(占
@@ -233,8 +264,8 @@ impl NebulaWorkspace {
let active_fg = theme.sidebar_accent_foreground;
let hover_bg = theme.list_hover;
let dark = theme.is_dark();
- // 标题/标签走稳定的 UI 字体;程序图标是 Nerd Font 字位,固定走随
- // 安装包提供的 Maple。用户设置的终端字体不得改变 chrome 几何。
+ // 路径标签沿用稳定的等宽字体;程序图标是 Nerd Font 字位,固定走随
+ // 安装包提供的 Maple。字号由独立的界面字号设置控制。
let settings = cx.try_global::();
let tab_close_visible = settings.map(|settings| settings.tab_close_visible).unwrap_or(true);
let tab_reveal = settings
@@ -242,12 +273,8 @@ impl NebulaWorkspace {
.unwrap_or(nebula_settings::TabRevealName::Slide);
let chrome_family = theme.mono_font_family.clone();
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
- // 旧壳合同(display/mod.rs `ui_font_px`):chrome 锚定**配置字号**
- // (nebula.toml `font.size` 默认 11.25pt = 15px)。终端的持久化缩放
- // (`font_size=` 键)只影响终端网格,侧栏不得跟着变粗/变大;
- // 固定 14px 的旧毛病(比旧壳小一号)也不能回潮。
- let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0);
- let cell_w = self.sidebar_cell_width(window, &chrome_family, label_px);
+ // 界面字号独立于终端缩放。
+ let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0);
// 受约束拖拽的渲染参数:激活后被拖行骑指针位移,落点槽位由位移换算。
let drag = self
@@ -279,25 +306,15 @@ impl NebulaWorkspace {
pane_count,
} = self.tab_presentation(ix, cx, dark);
let hover_group: SharedString = format!("sidebar-tab-hover-{ix}").into();
- // 可用列数 = (行宽 − 行内 px_2 − 行内 gap − 状态槽 − 行首图标槽)
- // ÷ cell 宽。基准取上面的 `row_w`(已扣掉侧栏 p_2 与滚动条留白),
- // 与行的实际宽度同源——否则算出的列数会比行能容纳的多出一格,
- // 截断后的标题反过来把行撑开。省略号由旧壳同一份
- // `truncate_tab_label` 追加,两壳的裁切位置因此一致。
- // 行首图标槽只有一个:身份图标(设置 / AI logo / 程序字位)优先,
- // 都没有时分屏标记才补位。三者互斥,所以扣一份宽即可。
let has_program_glyph = program_glyph.is_some();
- let has_icon =
- is_settings || logo_image.is_some() || has_program_glyph || pane_count > 1;
- let label_avail = row_w
- - 16.0
- - TAB_STATUS_SLOT_W
- - 8.0
- - if has_icon { TAB_LABEL_ICON_W + 8.0 } else { 0.0 }
- - if pane_count > 1 { pane_header::split_badge_slot_w(label_px) } else { 0.0 };
- let label_cols = (label_avail / cell_w).floor().max(1.0) as usize;
- let title: SharedString =
- crate::display::truncate_tab_label(&title, label_cols).into();
+ let status_width = Self::shell_status_width(
+ window,
+ shell_tag.as_ref(),
+ &chrome_family,
+ label_px * SIDEBAR_TAG_SCALE,
+ TAB_STATUS_SLOT_W,
+ row_w * 0.45,
+ );
let cross_window_drag = self.cross_window_drag_payload(ix, cx);
// 用户明确设置过的标签色:行左侧一条竖光条(旧壳 strip,位置与
// 尺寸同源:左内缩 4、上下各留 7、宽 2.5)。默认标签不占这层
@@ -364,13 +381,13 @@ impl NebulaWorkspace {
.into_any_element(),
),
SidebarActivity::Idle => shell_tag.map(|tag| {
- div()
- .font_family(chrome_family.clone())
- .text_size(px(label_px * SIDEBAR_TAG_SCALE))
- .font_weight(FontWeight::NORMAL)
- .text_color(status_color)
- .child(tag)
- .into_any_element()
+ Self::shell_status_label(
+ tag,
+ chrome_family.clone(),
+ label_px * SIDEBAR_TAG_SCALE,
+ status_color,
+ )
+ .into_any_element()
}),
};
// 三类行位移(旧壳 tab_drag_draw_y 的语义):被拖行骑指针,
@@ -517,10 +534,7 @@ impl NebulaWorkspace {
)
},
)
- // 标签走终端字体 + 终端字号(旧壳 chrome 同源)。文本已按列
- // 截断,这里只需要不换行;再叠一层 `truncate()` 会把省略号
- // 自己裁掉(用户报的"直接截断")。重命名中的那一行原地换成
- // 输入框:点进去不该触发选中/拖拽,所以自己吃掉 mouse_down。
+ // GPUI truncates the actual shaped title within its flex column.
.child(match renaming {
Some(input) => div()
.flex_1()
@@ -550,8 +564,7 @@ impl NebulaWorkspace {
// 标签标题本身使用 Light;活动态只换前景/背景色,
// 不再靠更粗字重强调,避免选中行看起来突然加粗。
.font_weight(FontWeight::LIGHT)
- .whitespace_nowrap()
- .overflow_hidden()
+ .truncate()
.child(title)
.into_any_element(),
})
@@ -574,11 +587,7 @@ impl NebulaWorkspace {
)
})
.child(
- div()
- .relative()
- .w(px(TAB_STATUS_SLOT_W))
- .h_full()
- .flex_shrink_0()
+ Self::tab_status_slot(status_width)
.when_some(resting_status, |slot, status| {
slot.child(
h_flex()
@@ -1005,7 +1014,10 @@ impl NebulaWorkspace {
.icon(IconName::Github)
.ghost()
.selected(git_active)
- .tooltip("Git 状态 (Ctrl+Shift+G)")
+ .tooltip(
+ crate::gpui_shell::config::ui_language(cx)
+ .text(crate::i18n::Message::VcsToggleGit),
+ )
.on_click(cx.listener(|this, _, _, cx| {
this.toggle_git_tree(cx);
})),
@@ -1043,7 +1055,7 @@ impl NebulaWorkspace {
let settings = cx.try_global::();
let chrome_family = theme.mono_font_family.clone();
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
- let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0);
+ let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0);
let TabPresentation { title, logo_image, program_glyph, pane_count, .. } =
self.tab_presentation(self.active, cx, dark);
slot.child(
@@ -1095,6 +1107,97 @@ impl NebulaWorkspace {
mod tests {
use super::*;
+ #[cfg(feature = "gpui-test-support")]
+ mod layout {
+ use super::*;
+ use std::cell::RefCell;
+ use std::rc::Rc;
+
+ struct StatusProbe {
+ label: SharedString,
+ font_size: f32,
+ mask: Rc>>>,
+ }
+
+ impl Render for StatusProbe {
+ fn render(
+ &mut self,
+ _window: &mut Window,
+ _cx: &mut Context,
+ ) -> impl IntoElement {
+ let mask = self.mask.clone();
+ h_flex()
+ .id("status-probe-row")
+ .debug_selector(|| "status-probe-row".to_owned())
+ .w(px(200.0))
+ .h(px(TAB_ROW_H))
+ .px_2()
+ .overflow_hidden()
+ .child(div().flex_1())
+ .child(
+ NebulaWorkspace::tab_status_slot(TAB_STATUS_SLOT_W)
+ .id("status-probe-slot")
+ .debug_selector(|| "status-probe-slot".to_owned())
+ .child(
+ h_flex().absolute().inset_0().justify_end().items_center().child(
+ NebulaWorkspace::shell_status_label(
+ self.label.clone(),
+ ".SystemUIFont".into(),
+ self.font_size,
+ gpui::black(),
+ )
+ .id("status-probe-label")
+ .debug_selector(|| "status-probe-label".to_owned()),
+ ),
+ )
+ .child(
+ canvas(
+ |_, _, _| {},
+ move |_, _, window, _| {
+ *mask.borrow_mut() = Some(window.content_mask().bounds);
+ },
+ )
+ .absolute()
+ .size_full(),
+ ),
+ )
+ }
+ }
+
+ #[gpui::test]
+ fn short_shells_align_right_long_shells_fit_and_glyph_ink_keeps_row_padding(
+ cx: &mut gpui::TestAppContext,
+ ) {
+ cx.update(gpui_component::init);
+ for font_size in [12.0, 15.0, 20.0] {
+ for label in ["sh", "debian", "a-very-long-shell-distribution-name"] {
+ let mask = Rc::new(RefCell::new(None));
+ let recorded = mask.clone();
+ let (_, visual) = cx.add_window_view(|_, _| StatusProbe {
+ label: label.into(),
+ font_size,
+ mask,
+ });
+ let row = visual.debug_bounds("status-probe-row").unwrap();
+ let slot = visual.debug_bounds("status-probe-slot").unwrap();
+ let text = visual.debug_bounds("status-probe-label").unwrap();
+ assert!(text.size.width <= slot.size.width);
+ assert_eq!(
+ text.right(),
+ slot.right(),
+ "shell labels retain the 1.7 right edge"
+ );
+ if label == "sh" {
+ assert!(text.size.width < slot.size.width, "short labels stay intrinsic");
+ }
+ let clip = recorded.borrow().expect("status paint mask");
+ assert!(clip.right() > slot.right(), "glyph overhang must retain row padding");
+ assert!(clip.right() <= row.right(), "the outer row still clips overflow");
+ }
+ }
+ }
+ }
+
#[test]
fn finished_dot_is_an_event_so_the_tab_you_are_watching_never_shows_it() {
// 你正盯着 agent 跑完:那个点在你眼前亮起没有任何信息量。
diff --git a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs
index 1a08a2d..b1ea7e7 100644
--- a/nebula_app/src/gpui_shell/workspace/tab_duplication.rs
+++ b/nebula_app/src/gpui_shell/workspace/tab_duplication.rs
@@ -5,7 +5,7 @@ use gpui::{Context, Window};
use super::NebulaWorkspace;
use crate::session::LaunchSession;
-fn inherit_guest_directory(launch: &mut LaunchSession, cwd: &str) -> bool {
+pub(super) fn inherit_guest_directory(launch: &mut LaunchSession, cwd: &str) -> bool {
let (program, args) = match launch {
LaunchSession::Shell { program, args, .. } => (program, args),
LaunchSession::Profile { command, args, .. } => (command, args),
diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs
index f5d369d..eddb08f 100644
--- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs
+++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs
@@ -143,7 +143,7 @@ impl NebulaWorkspace {
.unwrap_or(nebula_settings::TabRevealName::Slide);
let chrome_family = theme.mono_font_family.clone();
let symbol_family: SharedString = crate::font_install::REQUIRED_FONT_FAMILY.into();
- let label_px = settings.map(|settings| settings.base_font_size_px).unwrap_or(15.0);
+ let label_px = settings.map(|settings| settings.ui_font_size_px).unwrap_or(15.0);
let tab_capacity_w =
(f32::from(window.viewport_size().width) - TOP_TAB_RESERVED_W).max(TOP_TAB_MIN_W);
let tab_w = tab_width(tab_capacity_w, self.top_tab_count());
@@ -187,6 +187,14 @@ impl NebulaWorkspace {
let hover_group: SharedString = format!("top-tab-hover-{ix}").into();
let cross_window_drag = self.cross_window_drag_payload(ix, cx);
let status_color = if active { active_fg } else { muted };
+ let status_width = Self::shell_status_width(
+ window,
+ shell_tag.as_ref(),
+ &chrome_family,
+ label_px * 0.8,
+ TOP_TAB_STATUS_W,
+ tab_w * 0.4,
+ );
let resting_status: Option = match activity {
SidebarActivity::Running => {
items_running.set(true);
@@ -230,13 +238,13 @@ impl NebulaWorkspace {
.into_any_element(),
),
SidebarActivity::Idle => shell_tag.map(|tag| {
- div()
- .font_family(chrome_family.clone())
- .text_size(px(label_px * 0.8))
- .font_weight(FontWeight::NORMAL)
- .text_color(status_color)
- .child(tag)
- .into_any_element()
+ Self::shell_status_label(
+ tag,
+ chrome_family.clone(),
+ label_px * 0.8,
+ status_color,
+ )
+ .into_any_element()
}),
};
let strip = color.map(|color| gpui::Rgba {
@@ -451,11 +459,7 @@ impl NebulaWorkspace {
)
})
.child(
- div()
- .relative()
- .w(px(TOP_TAB_STATUS_W))
- .h_full()
- .flex_shrink_0()
+ Self::tab_status_slot(status_width)
.when_some(resting_status, |slot, status| {
slot.child(
h_flex()
@@ -707,7 +711,7 @@ impl NebulaWorkspace {
.icon(IconName::Github)
.ghost()
.selected(git_active)
- .tooltip("Git 状态 (Ctrl+Shift+G)")
+ .tooltip(crate::gpui_shell::config::ui_language(cx).text(crate::i18n::Message::VcsToggleGit))
.on_click(cx.listener(|this, _, _, cx| this.toggle_git_tree(cx))),
),
)
diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs
index 525421e..55c7d73 100644
--- a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs
+++ b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs
@@ -6,12 +6,19 @@
//! 在搬的过程中悄悄改掉某个 trait 的引入方式。
use super::*;
+use crate::i18n::{Message, UiLanguage};
use gpui_component::menu::PopupMenuItem;
+mod commit_input;
+mod relative_time;
+pub(super) use commit_input::CommitInput;
+use relative_time::git_relative_time_at;
+
impl NebulaWorkspace {
pub(super) fn render_side_panel_switch(&self, cx: &mut Context) -> impl IntoElement {
use crate::display::side_panel::PanelView;
+ let language = crate::gpui_shell::config::ui_language(cx);
let files = self.side_panel.view == PanelView::Files;
let git = self.side_panel.view == PanelView::Git;
let git_count = self
@@ -30,7 +37,7 @@ impl NebulaWorkspace {
.child(
Button::new("side-panel-files")
.icon(IconName::FolderClosed)
- .label("文件")
+ .label(language.text(Message::CommonFiles))
.small()
.selected(files)
.on_click(cx.listener(|this, _, _, cx| {
@@ -50,7 +57,13 @@ impl NebulaWorkspace {
)
}
- pub(super) fn render_git_tree(&mut self, cx: &mut Context) -> gpui::AnyElement {
+ pub(super) fn render_git_tree(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> gpui::AnyElement {
+ let language = crate::gpui_shell::config::ui_language(cx);
+ self.git_commit_input.sync_language(window, cx);
let view_switch = self.render_side_panel_switch(cx).into_any_element();
let theme = cx.theme();
let muted = theme.muted_foreground;
@@ -72,8 +85,8 @@ impl NebulaWorkspace {
let vcs = git.as_ref().map(|info| info.vcs);
let git_view = self.side_panel.git_view;
let op_running = self.side_panel.op_running();
- let op_error = self.side_panel.op_error();
- // 浏览定位是否生效——决定要不要画"回到终端当前目录"。链式构建里不能
+ let op_error = self.side_panel.localized_op_error(language);
+ // 浏览定位是否生效——决定要不要画“回到终端当前目录”。链式构建里不能
// 再借 `self`,所以这些和下面的弱引用都在这里一次取好。
let browsing_elsewhere = self.side_panel.custom_root_active();
// 下拉菜单的动作要在自己的闭包里回到本实体;`cx.listener` 只能给
@@ -106,7 +119,7 @@ impl NebulaWorkspace {
.px_2()
.text_sm()
.text_color(muted)
- .child("还没有提交历史")
+ .child(language.text(Message::VcsNoHistory))
.into_any_element(),
);
}
@@ -148,7 +161,7 @@ impl NebulaWorkspace {
let meta = format!(
"{} · {} · {}",
commit.author,
- git_relative_time(commit.timestamp),
+ git_relative_time(commit.timestamp, language),
commit.short_hash
);
rows.push(
@@ -188,10 +201,11 @@ impl NebulaWorkspace {
info.conflicts.iter().map(|(_, path)| path.as_str()).collect();
// 变更页保留三组;冲突页只列冲突。路径从后两组过滤,因为数据层
// 为旧壳兼容仍把冲突同时留在 staged/unstaged。
- let mut sections: Vec<(&str, Vec<&(char, String)>, RowOps)> = Vec::new();
+ let mut sections: Vec<(&str, &str, Vec<&(char, String)>, RowOps)> = Vec::new();
if !info.conflicts.is_empty() {
sections.push((
- "合并冲突",
+ "conflicts",
+ language.text(Message::VcsMergeConflicts),
info.conflicts.iter().collect(),
if is_git { RowOps::Conflict } else { RowOps::Svn },
));
@@ -201,25 +215,28 @@ impl NebulaWorkspace {
match info.vcs {
VcsKind::Git if git_view == GitPanelView::Changes => {
sections.push((
- "已暂存",
+ "staged",
+ language.text(Message::VcsStaged),
info.staged.iter().filter(not_conflicted).collect(),
RowOps::Staged,
));
sections.push((
- "变更",
+ "changes",
+ language.text(Message::VcsChanges),
info.unstaged.iter().filter(not_conflicted).collect(),
RowOps::Unstaged,
));
},
VcsKind::Git => {},
VcsKind::Svn => sections.push((
+ "svn",
"修改",
info.unstaged.iter().filter(not_conflicted).collect(),
RowOps::Svn,
)),
VcsKind::SvnRepository => {},
}
- let clean = sections.iter().all(|(_, entries, _)| entries.is_empty());
+ let clean = sections.iter().all(|(_, _, entries, _)| entries.is_empty());
if clean && info.vcs != VcsKind::SvnRepository {
rows.push(
div()
@@ -228,15 +245,15 @@ impl NebulaWorkspace {
.text_sm()
.text_color(muted)
.child(if is_git && git_view == GitPanelView::Conflicts {
- "没有待解决的冲突"
+ language.text(Message::VcsNoConflicts)
} else {
- "没有更改"
+ language.text(Message::VcsNoChanges)
})
.into_any_element(),
);
}
let discard_confirm = self.vcs_discard_confirm.clone();
- for (section, entries, ops) in sections {
+ for (section_id, section, entries, ops) in sections {
if entries.is_empty() {
continue;
}
@@ -272,7 +289,7 @@ impl NebulaWorkspace {
None => (relative_path.clone(), String::new()),
};
let row_group =
- SharedString::from(format!("vcs-row-actions-{section}-{index}"));
+ SharedString::from(format!("vcs-row-actions-{section_id}-{index}"));
let open_path = path.clone();
let stage_path = relative_path.clone();
let svn_add_path = relative_path.clone();
@@ -294,7 +311,7 @@ impl NebulaWorkspace {
rows.push(
h_flex()
.id(SharedString::from(format!(
- "git-tree-row-{section}-{index}-{relative_path}"
+ "git-tree-row-{section_id}-{index}-{relative_path}"
)))
.group(row_group.clone())
.h(px(30.0))
@@ -340,7 +357,7 @@ impl NebulaWorkspace {
.when(can_discard, |row| {
row.child(
Button::new(SharedString::from(format!(
- "vcs-discard-{section}-{index}"
+ "vcs-discard-{section_id}-{index}"
)))
.map(|button| {
if discard_armed {
@@ -348,7 +365,7 @@ impl NebulaWorkspace {
.label(if svn_revert {
"确认还原"
} else {
- "确认丢弃"
+ language.text(Message::VcsConfirmDiscard)
})
.danger()
.xsmall()
@@ -360,7 +377,7 @@ impl NebulaWorkspace {
.tooltip(if svn_revert {
"还原 SVN 改动"
} else {
- "丢弃改动"
+ language.text(Message::VcsDiscard)
})
}
})
@@ -396,12 +413,12 @@ impl NebulaWorkspace {
.when(ops == RowOps::Unstaged && is_git, |row| {
row.child(
Button::new(SharedString::from(format!(
- "vcs-stage-{section}-{index}"
+ "vcs-stage-{section_id}-{index}"
)))
.icon(IconName::Plus)
.ghost()
.xsmall()
- .tooltip("暂存")
+ .tooltip(language.text(Message::VcsStage))
.invisible()
.group_hover(row_group.clone(), |button| button.visible())
.on_click(
@@ -416,7 +433,7 @@ impl NebulaWorkspace {
.when(svn_add, |row| {
row.child(
Button::new(SharedString::from(format!(
- "svn-add-{section}-{index}"
+ "svn-add-{section_id}-{index}"
)))
.icon(IconName::Plus)
.ghost()
@@ -436,7 +453,7 @@ impl NebulaWorkspace {
.when(svn_resolve, |row| {
row.child(
Button::new(SharedString::from(format!(
- "svn-resolve-{section}-{index}"
+ "svn-resolve-{section_id}-{index}"
)))
.label("解决")
.ghost()
@@ -461,18 +478,18 @@ impl NebulaWorkspace {
&menu_target,
&menu_path,
index,
- section,
+ section_id,
))
})
.when(ops == RowOps::Staged, |row| {
row.child(
Button::new(SharedString::from(format!(
- "vcs-unstage-{section}-{index}"
+ "vcs-unstage-{section_id}-{index}"
)))
.icon(IconName::Minus)
.ghost()
.xsmall()
- .tooltip("取消暂存")
+ .tooltip(language.text(Message::VcsUnstage))
.invisible()
.group_hover(row_group.clone(), |button| button.visible())
.on_click(
@@ -487,7 +504,7 @@ impl NebulaWorkspace {
.when(ops == RowOps::Conflict && is_git, |row| {
row.child(
Button::new(SharedString::from(format!(
- "git-resolve-{section}-{index}"
+ "git-resolve-{section_id}-{index}"
)))
.icon(
Icon::new(Icon::empty())
@@ -495,7 +512,7 @@ impl NebulaWorkspace {
)
.ghost()
.xsmall()
- .tooltip("在三栏合并器中解决")
+ .tooltip(language.text(Message::VcsResolveInMergeEditor))
.on_click(
cx.listener(move |this, _, window, cx| {
this.open_git_merge_tab(
@@ -541,7 +558,7 @@ impl NebulaWorkspace {
.child(div().font_family(symbol).text_sm().text_color(muted).child("\u{ea68}"))
.child(div().flex_1().min_w_0().text_sm().truncate().child(
if info.branch.is_empty() {
- "(no branch)".to_owned()
+ language.text(Message::VcsNoBranch).to_owned()
} else {
info.branch.clone()
},
@@ -576,9 +593,11 @@ impl NebulaWorkspace {
.flex_1()
.ghost()
.small()
- .when(git_view == GitPanelView::Changes, |button| button.label("变更"))
+ .when(git_view == GitPanelView::Changes, |button| {
+ button.label(language.text(Message::VcsChanges))
+ })
.selected(git_view == GitPanelView::Changes)
- .tooltip("提交 / 变更")
+ .tooltip(language.text(Message::VcsCommitChanges))
.on_click(cx.listener(|this, _, _, cx| {
this.side_panel.select_git_view(GitPanelView::Changes);
cx.notify();
@@ -593,9 +612,11 @@ impl NebulaWorkspace {
.flex_1()
.ghost()
.small()
- .when(git_view == GitPanelView::History, |button| button.label("线路"))
+ .when(git_view == GitPanelView::History, |button| {
+ button.label(language.text(Message::VcsGraph))
+ })
.selected(git_view == GitPanelView::History)
- .tooltip("历史线路")
+ .tooltip(language.text(Message::VcsHistoryGraph))
.on_click(cx.listener(|this, _, _, cx| {
this.side_panel.select_git_view(GitPanelView::History);
cx.notify();
@@ -610,9 +631,11 @@ impl NebulaWorkspace {
.flex_1()
.ghost()
.small()
- .when(git_view == GitPanelView::Conflicts, |button| button.label("冲突"))
+ .when(git_view == GitPanelView::Conflicts, |button| {
+ button.label(language.text(Message::VcsConflicts))
+ })
.selected(git_view == GitPanelView::Conflicts)
- .tooltip("解决冲突")
+ .tooltip(language.text(Message::VcsResolveConflicts))
.on_click(cx.listener(|this, _, _, cx| {
this.side_panel.select_git_view(GitPanelView::Conflicts);
cx.notify();
@@ -628,7 +651,14 @@ impl NebulaWorkspace {
.flex_shrink_0()
.px(px(6.0))
.items_center()
- .child(div().flex_1().min_w_0().text_sm().font_semibold().child("历史线路"))
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .text_sm()
+ .font_semibold()
+ .child(language.text(Message::VcsHistoryGraph)),
+ )
.child(div().text_xs().text_color(muted).child(info.history.len().to_string()))
});
@@ -717,10 +747,10 @@ impl NebulaWorkspace {
h_flex()
.gap_1()
.items_center()
- .child(div().flex_1().min_w_0().child(Input::new(&self.git_commit_input)))
+ .child(div().flex_1().min_w_0().child(Input::new(&self.git_commit_input.input)))
.child(
Button::new("vcs-commit")
- .label("提交")
+ .label(language.text(Message::VcsCommit))
.small()
.disabled(!commit_ready)
.on_click(cx.listener(|this, _, window, cx| {
@@ -738,7 +768,7 @@ impl NebulaWorkspace {
.items_center()
.child(
Button::new("git-stage-all")
- .label("全部暂存")
+ .label(language.text(Message::VcsStageAll))
.small()
.disabled(op_running || unstaged_len == 0)
.on_click(cx.listener(|this, _, _, cx| {
@@ -748,7 +778,7 @@ impl NebulaWorkspace {
)
.child(
Button::new("git-pull")
- .label("拉取")
+ .label(language.text(Message::VcsPull))
.small()
.disabled(op_running)
.on_click(cx.listener(|this, _, _, cx| {
@@ -759,9 +789,12 @@ impl NebulaWorkspace {
.child(
Button::new("git-push")
.label(if ahead > 0 {
- SharedString::from(format!("推送 ↑{ahead}"))
+ SharedString::from(language.format(
+ Message::VcsPushAhead,
+ &[("count", &ahead.to_string())],
+ ))
} else {
- SharedString::from("推送")
+ SharedString::from(language.text(Message::VcsPush))
})
.small()
.disabled(op_running || ahead == 0)
@@ -893,7 +926,7 @@ impl NebulaWorkspace {
.when(browsing_elsewhere, |panel| {
panel.child(
Button::new("vcs-follow-cwd")
- .label("回到终端当前目录")
+ .label(language.text(Message::VcsFollowDirectory))
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, _, cx| {
@@ -918,11 +951,11 @@ impl NebulaWorkspace {
.when_some(action_strip, |panel, row| panel.child(row))
.when(git.is_none(), |panel| {
panel.child(
- div().py_3().text_sm().text_color(muted).child("当前目录不在 Git/SVN 仓库中"),
+ div().py_3().text_sm().text_color(muted).child(language.text(Message::VcsNotRepository)),
)
})
- .when_some(self.side_panel.root_notice(), |panel, notice| {
- panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned()))
+ .when_some(self.side_panel.localized_root_notice(language), |panel, notice| {
+ panel.child(div().text_xs().text_color(theme.warning).child(notice))
})
.child(
v_flex()
@@ -940,7 +973,7 @@ impl NebulaWorkspace {
.icon(IconName::Redo2)
.ghost()
.xsmall()
- .tooltip(format!("刷新 {vcs_label} 状态"))
+ .tooltip(language.format(Message::VcsRefreshStatus, &[("vcs", vcs_label)]))
.on_click(cx.listener(|this, _, _, cx| {
this.side_panel.request_refresh();
this.sync_side_panel_to_active(false, cx);
@@ -952,7 +985,7 @@ impl NebulaWorkspace {
.icon(IconName::Close)
.ghost()
.xsmall()
- .tooltip(format!("关闭 {vcs_label} 状态"))
+ .tooltip(language.format(Message::VcsCloseStatus, &[("vcs", vcs_label)]))
.on_click(cx.listener(|this, _, _, cx| {
this.toggle_git_tree(cx);
})),
@@ -1019,11 +1052,11 @@ impl NebulaWorkspace {
target: &gpui::WeakEntity,
path: &str,
index: usize,
- section: &str,
+ section_id: &str,
) -> impl IntoElement {
let target = target.clone();
let path = path.to_owned();
- Button::new(SharedString::from(format!("svn-row-more-{section}-{index}")))
+ Button::new(SharedString::from(format!("svn-row-more-{section_id}-{index}")))
.icon(IconName::Ellipsis)
.ghost()
.xsmall()
@@ -1427,31 +1460,17 @@ fn human_size(bytes: u64) -> String {
/// 提交时间的人读形式。Git 提供 Unix 秒,显示层自己计算,结果不受机器上
/// `git log` 的 locale 影响;未来时间(系统时钟回拨)按“刚刚”处理。
-fn git_relative_time(timestamp: i64) -> String {
+fn git_relative_time(timestamp: i64, language: UiLanguage) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)
.unwrap_or(timestamp);
- git_relative_time_at(timestamp, now)
-}
-
-fn git_relative_time_at(timestamp: i64, now: i64) -> String {
- let seconds = now.saturating_sub(timestamp).max(0);
- match seconds {
- 0..=59 => "刚刚".to_owned(),
- 60..=3599 => format!("{} 分钟前", seconds / 60),
- 3600..=86_399 => format!("{} 小时前", seconds / 3600),
- 86_400..=2_592_000 => format!("{} 天前", seconds / 86_400),
- 2_592_001..=31_536_000 => format!("{} 个月前", seconds / 2_592_000),
- _ => format!("{} 年前", seconds / 31_536_000),
- }
+ git_relative_time_at(timestamp, now, language)
}
#[cfg(test)]
mod tests {
- use super::{
- GitLaneEdgeKind, GitRefKind, git_graph_rows, git_ref_labels, git_relative_time_at,
- };
+ use super::{GitLaneEdgeKind, GitRefKind, git_graph_rows, git_ref_labels};
use crate::display::side_panel::GitCommit;
#[test]
@@ -1500,16 +1519,4 @@ mod tests {
assert_eq!(refs[3].label, "feature/ui");
assert_eq!(refs[3].kind, GitRefKind::Local);
}
-
- #[test]
- fn git_history_relative_time_uses_stable_boundaries() {
- let now = 100_000_000;
- assert_eq!(git_relative_time_at(now + 1, now), "刚刚");
- assert_eq!(git_relative_time_at(now - 59, now), "刚刚");
- assert_eq!(git_relative_time_at(now - 60, now), "1 分钟前");
- assert_eq!(git_relative_time_at(now - 3_600, now), "1 小时前");
- assert_eq!(git_relative_time_at(now - 86_400, now), "1 天前");
- assert_eq!(git_relative_time_at(now - 2_592_001, now), "1 个月前");
- assert_eq!(git_relative_time_at(now - 31_536_001, now), "1 年前");
- }
}
diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs
new file mode 100644
index 0000000..c774b5d
--- /dev/null
+++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs
@@ -0,0 +1,113 @@
+//! Keep the commit draft while refreshing its placeholder after a language change.
+
+use gpui::{App, AppContext as _, Entity, Window};
+use gpui_component::input::InputState;
+
+use crate::gpui_shell::config::ui_language;
+use crate::i18n::{Message, UiLanguage};
+
+pub(in crate::gpui_shell::workspace) struct CommitInput {
+ pub(in crate::gpui_shell::workspace) input: Entity,
+ language: UiLanguage,
+}
+
+#[cfg(all(test, feature = "gpui-test-support"))]
+mod tests {
+ use super::*;
+ use gpui::{Element as _, IntoElement as _, Render, RenderOnce as _, div};
+ use gpui_component::{ElementExt as _, input::Input};
+ use std::sync::{Arc, Mutex};
+
+ struct CommitInputProbe {
+ commit: CommitInput,
+ placeholder: Arc>>,
+ }
+
+ impl Render for CommitInputProbe {
+ fn render(
+ &mut self,
+ window: &mut Window,
+ cx: &mut gpui::Context,
+ ) -> impl gpui::IntoElement {
+ self.commit.sync_language(window, cx);
+ let input = self.commit.input.clone();
+ let placeholder = self.placeholder.clone();
+ div().on_prepaint(move |_, window, cx| {
+ let element = Input::new(&input).render(window, cx).into_element();
+ let mut node = gpui::accesskit::Node::new(gpui::accesskit::Role::TextInput);
+ element.write_a11y_info(&mut node);
+ *placeholder.lock().unwrap() = node.placeholder().map(str::to_owned);
+ })
+ }
+ }
+
+ #[gpui::test]
+ fn commit_placeholder_tracks_language_without_replacing_the_draft(
+ cx: &mut gpui::TestAppContext,
+ ) {
+ cx.update(gpui_component::init);
+ let placeholder = Arc::new(Mutex::new(None));
+ let captured = placeholder.clone();
+ // The early-startup fallback is English even before Settings is registered.
+ let (probe, cx) = cx.add_window_view(|window, cx| CommitInputProbe {
+ commit: CommitInput::new(window, cx),
+ placeholder,
+ });
+ cx.update(|window, cx| {
+ let _ = window.draw(cx);
+ });
+ assert_eq!(captured.lock().unwrap().as_deref(), Some("Commit message..."));
+ let input = probe.read_with(cx, |probe, _| probe.commit.input.clone());
+ cx.update(|window, cx| {
+ input.update(cx, |input, cx| {
+ input.set_value("fix: 保留 draft", window, cx);
+ input.set_selected_range(0..3, cx);
+ });
+ });
+ let original_id = input.entity_id();
+ let original_cursor = input.read_with(cx, |input, _| input.cursor_position());
+ let original_selection = input.read_with(cx, |input, _| input.selected_value());
+ assert_eq!(original_selection, "fix");
+ for (language, expected) in [
+ (UiLanguage::ZhCn, "提交信息…"),
+ (UiLanguage::for_locale(Some("en-GB")), "Commit message..."),
+ (UiLanguage::FrFr, "Commit message..."),
+ (UiLanguage::ZhCn, "提交信息…"),
+ ] {
+ cx.update(|window, cx| {
+ let mut settings =
+ crate::gpui_shell::config::Settings::load(nebula_settings::ThemeName::Nord);
+ settings.ui_language = language;
+ cx.set_global(settings);
+ probe.update(cx, |_, cx| cx.notify());
+ let _ = window.draw(cx);
+ });
+ assert_eq!(captured.lock().unwrap().as_deref(), Some(expected));
+ assert_eq!(probe.read_with(cx, |probe, _| probe.commit.input.entity_id()), original_id);
+ assert_eq!(input.read_with(cx, |input, _| input.value()), "fix: 保留 draft");
+ assert_eq!(input.read_with(cx, |input, _| input.cursor_position()), original_cursor);
+ assert_eq!(input.read_with(cx, |input, _| input.selected_value()), original_selection);
+ }
+ }
+}
+
+impl CommitInput {
+ pub(in crate::gpui_shell::workspace) fn new(window: &mut Window, cx: &mut App) -> Self {
+ let language = ui_language(cx);
+ let input = cx.new(|cx| {
+ InputState::new(window, cx).placeholder(language.text(Message::VcsCommitPlaceholder))
+ });
+ Self { input, language }
+ }
+
+ pub(super) fn sync_language(&mut self, window: &mut Window, cx: &mut App) {
+ let language = ui_language(cx);
+ if self.language == language {
+ return;
+ }
+ self.input.update(cx, |input, cx| {
+ input.set_placeholder(language.text(Message::VcsCommitPlaceholder), window, cx);
+ });
+ self.language = language;
+ }
+}
diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs
new file mode 100644
index 0000000..1b5285d
--- /dev/null
+++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs
@@ -0,0 +1,61 @@
+//! Localized Git history timestamps, independent of the renderer and system clock.
+
+use crate::i18n::{Message, UiLanguage};
+
+pub(super) fn git_relative_time_at(timestamp: i64, now: i64, language: UiLanguage) -> String {
+ let seconds = now.saturating_sub(timestamp).max(0);
+ let (count, singular, plural) = match seconds {
+ 0..=59 => return language.text(Message::VcsTimeNow).to_owned(),
+ 60..=3599 => (seconds / 60, Message::VcsTimeMinute, Message::VcsTimeMinutes),
+ 3600..=86_399 => (seconds / 3600, Message::VcsTimeHour, Message::VcsTimeHours),
+ 86_400..=2_592_000 => (seconds / 86_400, Message::VcsTimeDay, Message::VcsTimeDays),
+ 2_592_001..=31_536_000 => {
+ (seconds / 2_592_000, Message::VcsTimeMonth, Message::VcsTimeMonths)
+ },
+ _ => (seconds / 31_536_000, Message::VcsTimeYear, Message::VcsTimeYears),
+ };
+ if count == 1 {
+ language.text(singular).to_owned()
+ } else {
+ language.format(plural, &[("count", &count.to_string())])
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn git_history_relative_time_uses_stable_boundaries() {
+ let now = 100_000_000;
+ assert_eq!(git_relative_time_at(now + 1, now, UiLanguage::ZhCn), "刚刚");
+ assert_eq!(git_relative_time_at(now - 59, now, UiLanguage::ZhCn), "刚刚");
+ assert_eq!(git_relative_time_at(now - 60, now, UiLanguage::ZhCn), "1 分钟前");
+ assert_eq!(git_relative_time_at(now - 3_600, now, UiLanguage::ZhCn), "1 小时前");
+ assert_eq!(git_relative_time_at(now - 86_400, now, UiLanguage::ZhCn), "1 天前");
+ assert_eq!(git_relative_time_at(now - 2_592_001, now, UiLanguage::ZhCn), "1 个月前");
+ assert_eq!(git_relative_time_at(now - 31_536_001, now, UiLanguage::ZhCn), "1 年前");
+ }
+
+ #[test]
+ fn git_history_time_uses_the_active_language_and_english_fallback() {
+ let now = 100_000_000;
+ for (seconds, expected) in [
+ (-1, "just now"),
+ (59, "just now"),
+ (60, "1 minute ago"),
+ (120, "2 minutes ago"),
+ (3_600, "1 hour ago"),
+ (7_200, "2 hours ago"),
+ (86_400, "1 day ago"),
+ (172_800, "2 days ago"),
+ (2_592_001, "1 month ago"),
+ (5_184_000, "2 months ago"),
+ (31_536_001, "1 year ago"),
+ (63_072_000, "2 years ago"),
+ ] {
+ assert_eq!(git_relative_time_at(now - seconds, now, UiLanguage::EnUs), expected);
+ assert_eq!(git_relative_time_at(now - seconds, now, UiLanguage::FrFr), expected);
+ }
+ }
+}
diff --git a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs
index 2648b65..aa6c875 100644
--- a/nebula_app/src/gpui_shell/workspace/window_titlebar.rs
+++ b/nebula_app/src/gpui_shell/workspace/window_titlebar.rs
@@ -87,6 +87,7 @@ impl NebulaWorkspace {
cx: &mut Context,
) -> gpui::Div {
let top_tabs = self.tabs_position == nebula_settings::TabsPositionName::Top;
+ let native_layout = crate::platform::window_chrome::layout(window);
let bar = TitleBar::new()
// Leave 8px above and below the existing 32px controls.
.h(px(48.0))
@@ -94,8 +95,11 @@ impl NebulaWorkspace {
.when(settings_active, |bar| {
bar.border_b_1().border_color(crate::gpui_shell::theme::settings_hairline(cx))
})
+ .when(top_tabs && native_layout.is_none(), |bar| {
+ bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET))
+ })
.when(top_tabs, |bar| {
- bar.pl(px(top_tabs::TOP_TAB_LEFT_INSET)).child(self.render_top_title_bar(
+ bar.child(self.render_top_title_bar(
files_active,
git_active,
settings_active,
@@ -112,6 +116,14 @@ impl NebulaWorkspace {
))
});
+ // AppKit owns both the control group and its geometry. Read the live
+ // frames so system layout, resize and full-screen transitions agree.
+ let bar = if let Some((height, inset)) = native_layout {
+ bar.h(px(height)).pl(px(inset))
+ } else {
+ bar
+ };
+
div()
.relative()
.flex_shrink_0()
diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs
index 0fb0f58..9ea2a2c 100644
--- a/nebula_app/src/gpui_shell/workspace/windowing.rs
+++ b/nebula_app/src/gpui_shell/workspace/windowing.rs
@@ -374,7 +374,7 @@ fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> Wind
)
},
);
- WindowOptions {
+ crate::platform::window_chrome::configure_options(WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)),
titlebar: Some(TitleBar::title_bar_options()),
@@ -382,7 +382,7 @@ fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> Wind
window_background: crate::gpui_shell::wallpaper::initial_background_appearance(),
focus,
..Default::default()
- }
+ })
},
#[cfg(windows)]
WindowRole::QuickTerminal => {
@@ -439,13 +439,21 @@ fn open_workspace_window(
role: WindowRole,
) -> gpui::Result<(u64, Entity)> {
let (runtime_window_id, runtime_hub) = allocate_window(cx);
- let options = workspace_window_options(cx, focus, role);
+ let start_hidden = shell_events.is_some()
+ && matches!(startup, WorkspaceStartup::RestoreOrDefault)
+ && crate::platform::startup::start_hidden(&nebula_settings::RuntimeSettings::load());
+ let mut options = workspace_window_options(cx, focus, role);
+ if start_hidden {
+ options.show = false;
+ options.focus = false;
+ }
let workspace_slot = Rc::new(RefCell::new(None));
let hwnd_slot = Rc::new(RefCell::new(0isize));
let workspace_out = workspace_slot.clone();
let hwnd_out = hwnd_slot.clone();
let handle = cx.open_window(options, move |window, cx| {
window.set_window_title(crate::brand::NAME);
+ crate::platform::window_chrome::configure(window);
*hwnd_out.borrow_mut() = native_hwnd(window).unwrap_or_default();
#[cfg(windows)]
crate::gpui_shell::set_native_window_icon(window);
@@ -461,6 +469,7 @@ fn open_workspace_window(
cx,
)
});
+ workspace.update(cx, |workspace, _| workspace.window_hidden = start_hidden);
if runtime_window_id == 1
&& let Ok(path) = std::env::var("NEBULA_GPUI_OPEN_DOC")
&& !path.is_empty()
diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs
index 8c6195b..7769f5e 100644
--- a/nebula_app/src/main.rs
+++ b/nebula_app/src/main.rs
@@ -531,7 +531,9 @@ fn try_hand_over_to_resident(options: &Options) -> bool {
.window_options
.terminal_options
.resolved_working_directory()
- .filter(|path| path.is_dir());
+ .or_else(|| env::current_dir().ok())
+ .filter(|path| path.is_dir())
+ .and_then(|path| std::path::absolute(path).ok());
if !options.daemon
&& !has_command
&& nebula_settings::RuntimeSettings::load().windowing_behavior
@@ -539,9 +541,7 @@ fn try_hand_over_to_resident(options: &Options) -> bool {
{
return runtime_api::try_open_window_existing(launch_dir.as_deref());
}
- let plain_launch = !options.daemon
- && options.window_options.terminal_options.working_directory.is_none()
- && !has_command;
+ let plain_launch = !options.daemon && launch_dir.is_none() && !has_command;
if plain_launch && runtime_api::try_open_default_tab_existing() {
return true;
}
diff --git a/nebula_app/src/platform/ai_session_identity.rs b/nebula_app/src/platform/ai_session_identity.rs
index 31fc9e7..7cc1529 100644
--- a/nebula_app/src/platform/ai_session_identity.rs
+++ b/nebula_app/src/platform/ai_session_identity.rs
@@ -62,6 +62,13 @@ struct ProbeRecord {
first_line: String,
}
+/// Identity and working directory from the active rollout's own metadata.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct CodexSession {
+ pub session_id: String,
+ pub cwd: Option,
+}
+
/// Probe the active Codex rollout for one pane.
///
/// WSL panes are inspected inside the guest because Windows Toolhelp cannot
@@ -71,7 +78,7 @@ struct ProbeRecord {
pub(crate) fn probe_codex_session(
pane_id: u64,
exec_context: Option<&crate::runtime_exec::PaneExecContext>,
-) -> Option {
+) -> Option {
let pane_id = pane_id.to_string();
let context = exec_context?;
let instance = context.process_instance()?;
@@ -90,7 +97,7 @@ pub(crate) fn probe_codex_session(
}
}
-fn parse_probe_records(output: &str) -> Option {
+fn parse_probe_context(output: &str) -> Option {
let mut candidate = None;
for record in output.lines().filter_map(parse_probe_record) {
let Ok(meta) = serde_json::from_str::(&record.first_line) else {
@@ -118,17 +125,30 @@ fn parse_probe_records(output: &str) -> Option {
if filename_id != id {
continue;
}
- if candidate.as_ref().is_some_and(|existing| existing != id) {
+ let context = CodexSession {
+ session_id: id.to_owned(),
+ cwd: payload
+ .get("cwd")
+ .and_then(serde_json::Value::as_str)
+ .filter(|cwd| cwd.starts_with('/') && !cwd.chars().any(char::is_control))
+ .map(str::to_owned),
+ };
+ if candidate.as_ref().is_some_and(|existing| existing != &context) {
// More than one distinct main/user rollout is ambiguous. A
// process can have guardian/sub-agent files open, but it must not
// have two possible user conversations silently choose one.
return None;
}
- candidate = Some(id.to_owned());
+ candidate = Some(context);
}
candidate
}
+#[cfg(test)]
+fn parse_probe_records(output: &str) -> Option {
+ parse_probe_context(output).map(|context| context.session_id)
+}
+
fn parse_probe_record(line: &str) -> Option {
let mut fields = line.splitn(4, '\t');
Some(ProbeRecord {
@@ -170,7 +190,7 @@ fn probe_wsl(
user: Option<&str>,
pane_id: &str,
instance: &str,
-) -> Option {
+) -> Option {
let mut command = Command::new("wsl.exe");
command.args(wsl_probe_args(distro, user, pane_id, instance));
#[cfg(windows)]
@@ -208,7 +228,7 @@ fn wsl_probe_args(
/// Bound the guest lookup by time and output size. A temporary file avoids a
/// reader thread surviving when a descendant keeps the output handle open.
-fn run_probe_command(mut command: Command) -> Option {
+fn run_probe_command(mut command: Command) -> Option {
let mut output = tempfile::tempfile().ok()?;
command.stdin(Stdio::null()).stdout(output.try_clone().ok()?).stderr(Stdio::null());
let mut child = command.spawn().ok()?;
@@ -238,7 +258,7 @@ fn run_probe_command(mut command: Command) -> Option {
};
output.rewind().ok()?;
let output = read_bounded(output, MAX_PROBE_OUTPUT).ok()?;
- status.success().then(|| parse_probe_records(&String::from_utf8_lossy(&output)))?
+ status.success().then(|| parse_probe_context(&String::from_utf8_lossy(&output)))?
}
fn read_bounded(mut reader: impl Read, limit: usize) -> io::Result> {
@@ -258,7 +278,7 @@ fn read_bounded(mut reader: impl Read, limit: usize) -> io::Result> {
}
#[cfg(unix)]
-fn probe_local_proc(pane_id: &str, instance: &str) -> Option {
+fn probe_local_proc(pane_id: &str, instance: &str) -> Option {
let deadline = Instant::now() + PROBE_TIMEOUT;
let mut output = String::new();
let mut records = 0;
@@ -309,7 +329,7 @@ fn probe_local_proc(pane_id: &str, instance: &str) -> Option {
}
}
}
- parse_probe_records(&output)
+ parse_probe_context(&output)
}
#[cfg(unix)]
@@ -400,6 +420,26 @@ mod tests {
assert!(read_bounded(&b"12345"[..], 4).is_err());
}
+ #[test]
+ fn review_regression_active_rollout_supplies_cwd_without_accepting_invalid_paths() {
+ for (cwd, expected) in [
+ ("/mnt/d/temp_build/project", Some("/mnt/d/temp_build/project")),
+ ("/home/hello/项目 with spaces", Some("/home/hello/项目 with spaces")),
+ ("relative/path", None),
+ ("/bad\npath", None),
+ ] {
+ let metadata = serde_json::json!({"type":"session_meta", "payload": {
+ "id": ROOT_ID, "cwd": cwd, "source": "cli", "thread_source": "user",
+ }});
+ let output = format!(
+ "42\t7\t/home/hello/.codex/sessions/rollout-x-{ROOT_ID}.jsonl\t{metadata}\n"
+ );
+ let context = parse_probe_context(&output).unwrap();
+ assert_eq!(context.session_id, ROOT_ID);
+ assert_eq!(context.cwd.as_deref(), expected);
+ }
+ }
+
#[test]
fn selects_cli_user_rollout_and_rejects_guardian_thread() {
let output = format!(
diff --git a/nebula_app/src/platform/capabilities.rs b/nebula_app/src/platform/capabilities.rs
index 64bb7d8..fd37c67 100644
--- a/nebula_app/src/platform/capabilities.rs
+++ b/nebula_app/src/platform/capabilities.rs
@@ -15,6 +15,8 @@ pub struct Capabilities {
pub hide_window_on_close: bool,
/// 系统托盘图标(`crate::tray`)。
pub system_tray: bool,
+ /// Manage the per-user login startup entry.
+ pub launch_at_login: bool,
/// 系统通知后端已实现;实际投递仍受系统通知权限控制。
pub system_notifications: bool,
/// 系统提示音(`platform::beep`)。
@@ -39,6 +41,7 @@ pub const CAPABILITIES: Capabilities = {
Capabilities {
hide_window_on_close: true,
system_tray: true,
+ launch_at_login: true,
system_notifications: true,
system_bell: true,
self_update_install: true,
@@ -54,6 +57,7 @@ pub const CAPABILITIES: Capabilities = {
Capabilities {
hide_window_on_close: false,
system_tray: false,
+ launch_at_login: false,
system_notifications: true,
system_bell: false,
self_update_install: false,
@@ -75,6 +79,7 @@ mod tests {
let all = [
CAPABILITIES.hide_window_on_close,
CAPABILITIES.system_tray,
+ CAPABILITIES.launch_at_login,
CAPABILITIES.system_notifications,
CAPABILITIES.system_bell,
CAPABILITIES.self_update_install,
diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs
index 9dce90c..76d7fdd 100644
--- a/nebula_app/src/platform/mod.rs
+++ b/nebula_app/src/platform/mod.rs
@@ -29,6 +29,8 @@ pub(crate) mod process_snapshot;
pub mod shell;
pub mod shell_integration;
pub mod startup;
+#[cfg(feature = "gpui-shell")]
+pub(crate) mod window_chrome;
pub use capabilities::CAPABILITIES;
diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs
index 06a5e15..f2ce192 100644
--- a/nebula_app/src/platform/startup.rs
+++ b/nebula_app/src/platform/startup.rs
@@ -21,3 +21,91 @@ pub fn prepare_gui() {
}
super::notifications::prepare();
}
+
+pub(crate) fn start_hidden(settings: &nebula_settings::RuntimeSettings) -> bool {
+ super::CAPABILITIES.hide_window_on_close && settings.silent_start && settings.tray
+}
+
+/// The installer and Settings manage the same per-user Startup shortcut.
+pub(crate) fn launch_at_login() -> bool {
+ #[cfg(windows)]
+ return startup_shortcut().is_ok_and(|path| path.is_file());
+ #[cfg(not(windows))]
+ false
+}
+
+pub(crate) fn set_launch_at_login(enabled: bool) -> std::io::Result<()> {
+ #[cfg(windows)]
+ {
+ use windows::Win32::System::Com::{
+ CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx,
+ CoUninitialize, IPersistFile,
+ };
+ use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
+ use windows::core::{HSTRING, Interface, w};
+
+ let path = startup_shortcut()?;
+ if !enabled {
+ return match std::fs::remove_file(path) {
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ result => result,
+ };
+ }
+ let executable = std::env::current_exe()?;
+ // SAFETY: COM calls stay on this thread; interfaces are dropped before
+ // balancing the successful initialization, including on save errors.
+ unsafe {
+ CoInitializeEx(None, COINIT_APARTMENTTHREADED).ok().map_err(std::io::Error::other)?;
+ let result = (|| -> windows::core::Result<()> {
+ let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
+ link.SetPath(&HSTRING::from(executable.as_os_str()))?;
+ link.SetArguments(w!("--gpui"))?;
+ if let Some(home) = super::dirs::home_dir() {
+ link.SetWorkingDirectory(&HSTRING::from(home.as_os_str()))?;
+ }
+ link.cast::()?.Save(&HSTRING::from(path.as_os_str()), true)
+ })();
+ CoUninitialize();
+ result.map_err(std::io::Error::other)
+ }
+ }
+ #[cfg(not(windows))]
+ {
+ let _ = enabled;
+ Err(std::io::ErrorKind::Unsupported.into())
+ }
+}
+
+#[cfg(windows)]
+fn startup_shortcut() -> std::io::Result {
+ use std::os::windows::ffi::OsStringExt;
+ use windows::Win32::System::Com::CoTaskMemFree;
+ use windows::Win32::UI::Shell::{FOLDERID_Startup, KF_FLAG_DEFAULT, SHGetKnownFolderPath};
+
+ // SAFETY: the known-folder buffer is copied before its COM allocation is freed.
+ unsafe {
+ let path = SHGetKnownFolderPath(&FOLDERID_Startup, KF_FLAG_DEFAULT, None)
+ .map_err(std::io::Error::other)?;
+ let directory = std::ffi::OsString::from_wide(path.as_wide());
+ CoTaskMemFree(Some(path.0.cast()));
+ Ok(std::path::PathBuf::from(directory).join("Pebrel.lnk"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn silent_start_requires_a_tray_and_native_window_hiding() {
+ use nebula_settings::{RawSettings, RuntimeSettings};
+
+ for (text, expected) in [
+ ("", false),
+ ("silent_start=1\ntray=0", false),
+ ("silent_start=0\ntray=1", false),
+ ("silent_start=1\ntray=1", super::super::CAPABILITIES.hide_window_on_close),
+ ] {
+ let settings = RuntimeSettings::from_raw(&RawSettings::from_text(text));
+ assert_eq!(super::start_hidden(&settings), expected);
+ }
+ }
+}
diff --git a/nebula_app/src/platform/window_chrome.rs b/nebula_app/src/platform/window_chrome.rs
new file mode 100644
index 0000000..47424a0
--- /dev/null
+++ b/nebula_app/src/platform/window_chrome.rs
@@ -0,0 +1,43 @@
+//! Native window decoration belongs to the platform boundary. Workspace code
+//! supplies ordinary GPUI options and uses optional geometry without depending
+//! on AppKit types or duplicating platform detection.
+
+use gpui::{Window, WindowOptions};
+
+#[cfg(target_os = "macos")]
+mod macos;
+
+pub(crate) fn configure_options(options: WindowOptions) -> WindowOptions {
+ #[cfg(target_os = "macos")]
+ {
+ let mut options = options;
+ if let Some(titlebar) = options.titlebar.as_mut() {
+ // A manual position makes GPUI rewrite AppKit's titlebar container,
+ // breaking the native toolbar layout.
+ titlebar.traffic_light_position = None;
+ }
+ options.app_owns_titlebar_drag = true;
+ options
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ options
+ }
+}
+
+pub(crate) fn configure(_window: &Window) {
+ #[cfg(target_os = "macos")]
+ macos::configure(_window);
+}
+
+/// Height and left inset when native controls determine the titlebar geometry.
+pub(crate) fn layout(_window: &Window) -> Option<(f32, f32)> {
+ #[cfg(target_os = "macos")]
+ {
+ Some(macos::layout(_window))
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ None
+ }
+}
diff --git a/nebula_app/src/platform/window_chrome/macos.rs b/nebula_app/src/platform/window_chrome/macos.rs
new file mode 100644
index 0000000..016c80d
--- /dev/null
+++ b/nebula_app/src/platform/window_chrome/macos.rs
@@ -0,0 +1,80 @@
+//! Keep the standard window controls in AppKit's native toolbar hierarchy.
+
+use gpui::Window;
+use objc2::{MainThreadOnly, rc::Retained};
+use objc2_app_kit::{NSToolbar, NSView, NSWindow, NSWindowButton, NSWindowToolbarStyle};
+use objc2_foundation::{NSRect, ns_string};
+use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
+
+fn native_window(window: &Window) -> Option> {
+ let handle = HasWindowHandle::window_handle(window).ok()?;
+ let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return None };
+ // SAFETY: GPUI owns this live NSView and calls the adapter on the UI thread.
+ let view = unsafe { handle.ns_view.cast::().as_ref() };
+ view.window()
+}
+
+pub(super) fn configure(window: &Window) {
+ let Some(native) = native_window(window) else { return };
+ let toolbar = NSToolbar::initWithIdentifier(
+ NSToolbar::alloc(native.mtm()),
+ ns_string!("pebrel-window-controls"),
+ );
+ // An empty native toolbar supplies the system's current window-control
+ // treatment. AppKit retains it; GPUI continues to render the app controls.
+ native.setToolbarStyle(NSWindowToolbarStyle::Unified);
+ native.setToolbar(Some(&toolbar));
+}
+
+pub(super) fn layout(window: &Window) -> (f32, f32) {
+ if window.is_fullscreen() {
+ return (52.0, 8.0);
+ }
+ let Some(native) = native_window(window) else { return (52.0, 96.0) };
+ let Some(close) = native.standardWindowButton(NSWindowButton::CloseButton) else {
+ return (52.0, 96.0);
+ };
+ let Some(zoom) = native.standardWindowButton(NSWindowButton::ZoomButton) else {
+ return (52.0, 96.0);
+ };
+ let Some(content) = native.contentView() else { return (52.0, 96.0) };
+ let close = close.convertRect_toView(close.bounds(), Some(&content));
+ let zoom = zoom.convertRect_toView(zoom.bounds(), Some(&content));
+ control_layout(close, zoom, content.bounds(), content.isFlipped())
+}
+
+fn control_layout(close: NSRect, zoom: NSRect, content: NSRect, flipped: bool) -> (f32, f32) {
+ let center = close.origin.y + close.size.height / 2.0;
+ let center_from_top = if flipped {
+ center - content.origin.y
+ } else {
+ content.origin.y + content.size.height - center
+ };
+ (
+ 2.0 * center_from_top as f32,
+ (zoom.origin.x + zoom.size.width - content.origin.x + 16.0) as f32,
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use objc2_foundation::{NSPoint, NSSize};
+
+ #[test]
+ fn app_controls_follow_native_centers_and_leave_room_for_the_group() {
+ for height in [600.0, 900.0] {
+ for (inset, center) in [(19.0, 26.0), (12.0, 20.0)] {
+ for flipped in [false, true] {
+ let content = NSRect::new(NSPoint::new(5.0, 3.0), NSSize::new(900.0, height));
+ let y = 3.0 + if flipped { center - 7.0 } else { height - center - 7.0 };
+ let close = NSRect::new(NSPoint::new(5.0 + inset, y), NSSize::new(14.0, 14.0));
+ let zoom = NSRect::new(NSPoint::new(5.0 + inset + 46.0, y), close.size);
+ let (bar_height, left_padding) = control_layout(close, zoom, content, flipped);
+ assert_eq!(bar_height / 2.0, center as f32);
+ assert_eq!(left_padding, (inset + 46.0 + 14.0 + 16.0) as f32);
+ }
+ }
+ }
+ }
+}
diff --git a/nebula_app/src/product_ui/mod.rs b/nebula_app/src/product_ui/mod.rs
index b11f7f7..073586a 100644
--- a/nebula_app/src/product_ui/mod.rs
+++ b/nebula_app/src/product_ui/mod.rs
@@ -86,12 +86,13 @@ pub(crate) use command_completion::{
nebula_commands_handle, nebula_is_command_position, nebula_path_wants_directory,
};
pub(crate) use file_operations::send_to_recycle_bin;
+#[cfg(windows)]
+pub(crate) use input_state::nebula_input_from_raw_grid;
pub(crate) use input_state::{
nebula_clear_line, nebula_input_backspace, nebula_input_char, nebula_input_delete_word,
- nebula_input_text, nebula_shell_prompt_restored_from_raw_grid,
+ nebula_input_text, nebula_prompt_line_from_raw_grid,
+ nebula_shell_prompt_restored_from_raw_grid, nebula_shell_ready_from_raw_grid,
};
-#[cfg(windows)]
-pub(crate) use input_state::{nebula_input_from_raw_grid, nebula_prompt_line_from_raw_grid};
pub(crate) use network_proxy_model::{
MANUAL_PROXY_PROTOCOL_OPTIONS, ManualProxyProtocol, ProxyTestStatus, manual_proxy_parts,
manual_proxy_value,
diff --git a/nebula_app/tests/i18n_contract.rs b/nebula_app/tests/i18n_contract.rs
index 0862483..42062dc 100644
--- a/nebula_app/tests/i18n_contract.rs
+++ b/nebula_app/tests/i18n_contract.rs
@@ -2,6 +2,10 @@
mod catalog_builder;
#[path = "../src/i18n/mod.rs"]
mod i18n;
+#[path = "../src/display/side_panel/notice.rs"]
+mod panel_notice;
+#[path = "../src/gpui_shell/workspace/vcs_panel/relative_time.rs"]
+mod vcs_relative_time;
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
@@ -48,6 +52,8 @@ fn first_and_repeated_translation_lookups_allocate_nothing() {
for language in i18n::UiLanguage::ALL {
for _ in 0..1_000 {
black_box(language.text(black_box(i18n::Message::SettingsSidebarNetwork)));
+ black_box(language.text(black_box(i18n::Message::VcsChanges)));
+ black_box(language.text(black_box(i18n::Message::VcsCommitPlaceholder)));
black_box(language.tr(black_box("settings.sidebar.network")));
black_box(language.pick(black_box("网络"), black_box("Network")));
black_box(language.pick(black_box("新文案"), black_box("Unmigrated text")));
@@ -62,3 +68,13 @@ fn embedded_translations_stay_within_the_initial_payload_budget() {
assert!(i18n::TRANSLATED_BYTES < 256 * 1024);
assert!(i18n::MESSAGE_COUNT >= 200);
}
+
+#[test]
+fn vcs_messages_follow_resolved_english_and_fall_back_for_partial_locales() {
+ let english = i18n::UiLanguage::for_locale(Some("en-GB"));
+ assert_eq!(english.tr("vcs.changes"), "Changes");
+ assert_eq!(english.tr("vcs.commit_placeholder"), "Commit message...");
+ assert_eq!(i18n::UiLanguage::ZhCn.tr("vcs.changes"), "变更");
+ assert_eq!(i18n::UiLanguage::FrFr.tr("vcs.changes"), "Changes");
+ assert_eq!(english.tr_args("vcs.refresh_status", &[("vcs", "Git")]), "Refresh Git status");
+}
diff --git a/nebula_app/windows/nebula.rc b/nebula_app/windows/nebula.rc
index e167b42..5ed706b 100644
--- a/nebula_app/windows/nebula.rc
+++ b/nebula_app/windows/nebula.rc
@@ -10,8 +10,8 @@ APP_MANIFEST RT_MANIFEST nebula.manifest
#endif
1 VERSIONINFO
-FILEVERSION 1,7,0,0
-PRODUCTVERSION 1,7,0,0
+FILEVERSION 1,8,0,0
+PRODUCTVERSION 1,8,0,0
FILEFLAGSMASK 0x3fL
FILEFLAGS 0x0L
FILEOS 0x40004L
@@ -24,11 +24,11 @@ BEGIN
BEGIN
VALUE "CompanyName", "Kuddev"
VALUE "FileDescription", "Pebrel"
- VALUE "FileVersion", "1.7.0"
+ VALUE "FileVersion", "1.8.0"
VALUE "InternalName", "nebula"
VALUE "OriginalFilename", "pebrel.exe"
VALUE "ProductName", "Pebrel"
- VALUE "ProductVersion", "1.7.0"
+ VALUE "ProductVersion", "1.8.0"
END
END
BLOCK "VarFileInfo"
diff --git a/nebula_config/Cargo.toml b/nebula_config/Cargo.toml
index dbd27a6..dc15094 100644
--- a/nebula_config/Cargo.toml
+++ b/nebula_config/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula_config"
-version = "1.7.0"
+version = "1.8.0"
license = "GPL-3.0-or-later"
description = "Nebula configuration abstractions"
homepage = "https://github.com/Kuddev/pebrel"
@@ -14,5 +14,5 @@ serde = "1.0.163"
toml.workspace = true
[dev-dependencies]
-nebula_config_derive = { version = "1.7.0", path = "../nebula_config_derive" }
+nebula_config_derive = { version = "1.8.0", path = "../nebula_config_derive" }
serde = { version = "1.0.163", features = ["derive"] }
diff --git a/nebula_config_derive/Cargo.toml b/nebula_config_derive/Cargo.toml
index fc5d038..2828977 100644
--- a/nebula_config_derive/Cargo.toml
+++ b/nebula_config_derive/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula_config_derive"
-version = "1.7.0"
+version = "1.8.0"
license = "GPL-3.0-or-later"
description = "Failure resistant deserialization derive"
homepage = "https://github.com/Kuddev/pebrel"
@@ -18,7 +18,7 @@ syn = { version = "2.0.16", features = ["derive", "parsing", "proc-macro", "prin
[dev-dependencies.nebula_config]
path = "../nebula_config"
-version = "1.7.0"
+version = "1.8.0"
[dev-dependencies]
log = "0.4.11"
diff --git a/nebula_settings/Cargo.toml b/nebula_settings/Cargo.toml
index 43cfcce..c7c29f3 100644
--- a/nebula_settings/Cargo.toml
+++ b/nebula_settings/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula-settings"
-version = "1.7.0"
+version = "1.8.0"
license = "GPL-3.0-or-later"
description = "Shared reader for Nebula runtime user settings (nebula_settings.txt) and theme terminal-color tables"
publish = false
diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs
index 4fb809e..9e2112e 100644
--- a/nebula_settings/src/lib.rs
+++ b/nebula_settings/src/lib.rs
@@ -950,6 +950,9 @@ pub struct RuntimeSettings {
/// 系统外观变化时自动在主题家族的深浅成员间切换(默认关,尊重显式选择)。
pub follow_system_theme: bool,
pub font_family: Option,
+ pub font_family_cjk: Option,
+ pub ui_font_family: Option,
+ pub ui_font_size_px: Option,
/// **逻辑像素**(旧壳写盘语义:设置页 spinner 与 Ctrl+滚轮缩放持久化时
/// 已除以 scale factor)。`None` = 跟随 nebula.toml 的 `font.size`(pt)。
pub font_size_px: Option,
@@ -997,6 +1000,8 @@ pub struct RuntimeSettings {
/// from the Application settings page when this is disabled.
pub auto_check_updates: bool,
pub keep_session: bool,
+ /// Start the first window hidden when a system tray is available and enabled.
+ pub silent_start: bool,
pub restore_session: bool,
pub resume_ai: bool,
/// 常驻系统托盘图标。
@@ -1108,6 +1113,9 @@ impl RuntimeSettings {
.unwrap_or_default(),
follow_system_theme: raw.bool_on("follow_system_theme").unwrap_or(false),
font_family: raw.value("font_family").map(str::to_owned),
+ font_family_cjk: raw.value("font_family_cjk").map(str::to_owned),
+ ui_font_family: raw.value("ui_font_family").map(str::to_owned),
+ ui_font_size_px: raw.f32("ui_font_size").map(|size| size.clamp(10.0, 24.0)),
font_size_px: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)),
cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings),
cursor_blink: raw.bool_on("cursor_blink"),
@@ -1157,6 +1165,7 @@ impl RuntimeSettings {
fetch: raw.bool_on("fetch").unwrap_or(false),
auto_check_updates: raw.bool_on("auto_check_updates").unwrap_or(true),
keep_session: raw.bool_on("keep_session").unwrap_or(false),
+ silent_start: raw.bool_on("silent_start").unwrap_or(false),
restore_session: raw.bool_on("restore_session").unwrap_or(true),
resume_ai: raw.bool_on("resume_ai").unwrap_or(true),
tray: raw.bool_on("tray").unwrap_or(true),
@@ -1235,13 +1244,19 @@ impl RuntimeSettings {
}
}
-/// 解析 `#rrggbb`(旧壳 `parse_hex_rgb` 同款:# 前缀可省)。
+/// 解析 `#rgb` 或 `#rrggbb`;# 前缀可省,写盘统一使用六位形式。
pub fn parse_hex_rgb(value: &str) -> Option {
let hex = value.trim();
let hex = hex.strip_prefix('#').unwrap_or(hex);
- if hex.len() != 6 || !hex.is_ascii() {
+ if !matches!(hex.len(), 3 | 6) || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
+ if hex.len() == 3 {
+ let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
+ let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
+ let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
+ return Some([r, g, b]);
+ }
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
@@ -1377,6 +1392,9 @@ mod tests {
completion_style=popup\n\
shell=pwsh\n\
font_family=Maple Mono Normal NF CN\n\
+ ui_font_family=Arial\n\
+ ui_font_size=18\n\
+ font_family_cjk=PingFang SC\n\
font_size=16.3\n\
cursor_shape=beam\n\
cursor_blink=1\n\
@@ -1394,6 +1412,7 @@ mod tests {
fetch=1\n\
powerline=0\n\
keep_session=1\n\
+ silent_start=1\n\
restore_session=0\n\
resume_ai=0\n\
tray=0\n\
@@ -1413,6 +1432,9 @@ mod tests {
assert_eq!(settings.language, LanguagePref::ZhCn);
assert_eq!(settings.theme, ThemeName::SilverLight);
assert_eq!(settings.font_family.as_deref(), Some("Maple Mono Normal NF CN"));
+ assert_eq!(settings.font_family_cjk.as_deref(), Some("PingFang SC"));
+ assert_eq!(settings.ui_font_family.as_deref(), Some("Arial"));
+ assert_eq!(settings.ui_font_size_px, Some(18.0));
// font_size 键存的是逻辑像素(旧壳写盘语义),不做 pt 换算。
assert_eq!(settings.font_size_px, Some(16.3));
assert_eq!(settings.cursor_shape, Some(CursorShapeName::Beam));
@@ -1433,6 +1455,7 @@ mod tests {
assert_eq!(settings.cell_width_mode, CellWidthModeName::Relaxed);
assert!(settings.fetch);
assert!(settings.keep_session);
+ assert!(settings.silent_start);
assert!(!settings.restore_session);
assert!(!settings.resume_ai);
assert!(!settings.tray);
@@ -1457,6 +1480,9 @@ mod tests {
assert_eq!(settings.language, LanguagePref::System);
assert_eq!(settings.theme, ThemeName::Nord);
assert_eq!(settings.font_family, None);
+ assert_eq!(settings.font_family_cjk, None);
+ assert_eq!(settings.ui_font_family, None);
+ assert_eq!(settings.ui_font_size_px, None);
assert!(!settings.copy_on_select);
assert!(settings.multiline_paste_confirm, "多行粘贴确认默认开(上游兼容)");
assert!(settings.tab_close_visible, "标签关闭按钮默认可见(上游兼容)");
@@ -1475,6 +1501,7 @@ mod tests {
assert!(!settings.fetch);
assert!(settings.auto_check_updates);
assert!(!settings.keep_session);
+ assert!(!settings.silent_start);
assert!(settings.restore_session);
assert!(settings.resume_ai);
assert!(settings.tray);
@@ -1667,6 +1694,18 @@ mod tests {
assert_eq!(format_hex_rgb([0x8b, 0xd5, 0xca]), "#8bd5ca");
}
+ #[test]
+ fn shorthand_rgb_expands_and_rejects_non_hex_or_alpha_input() {
+ for value in ["#123", "123", " #123 "] {
+ assert_eq!(parse_hex_rgb(value), Some([0x11, 0x22, 0x33]), "{value:?}");
+ }
+ assert_eq!(parse_hex_rgb("#aBc"), Some([0xaa, 0xbb, 0xcc]));
+ assert_eq!(format_hex_rgb(parse_hex_rgb("#aBc").unwrap()), "#aabbcc");
+ for value in ["", "#", "#12", "#1234", "#12345", "#12345678", "#12g", "+1b2c3", "中文"] {
+ assert_eq!(parse_hex_rgb(value), None, "{value:?}");
+ }
+ }
+
#[test]
fn theme_names_roundtrip() {
assert_eq!(ThemeName::Nebula.prompt_name(), "Nebula");
diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs
index 84c36c2..57ad9ef 100644
--- a/nebula_settings/src/reset.rs
+++ b/nebula_settings/src/reset.rs
@@ -10,6 +10,9 @@ const RESET_KEYS: &[&str] = &[
"app_icon",
"follow_system_theme",
"font_family",
+ "font_family_cjk",
+ "ui_font_family",
+ "ui_font_size",
"font_size",
"cursor_shape",
"cursor_blink",
@@ -42,6 +45,7 @@ const RESET_KEYS: &[&str] = &[
"restore_session",
"resume_ai",
"tray",
+ "silent_start",
"blur",
"opacity",
"background",
diff --git a/nebula_split/Cargo.toml b/nebula_split/Cargo.toml
index b1fa368..f57f9fe 100644
--- a/nebula_split/Cargo.toml
+++ b/nebula_split/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula-split"
-version = "1.7.0"
+version = "1.8.0"
license = "GPL-3.0-or-later"
description = "Shared split-pane rules: layout tree, geometry, divider drag and directional focus"
publish = false
diff --git a/nebula_terminal/Cargo.toml b/nebula_terminal/Cargo.toml
index 8a40565..b712354 100644
--- a/nebula_terminal/Cargo.toml
+++ b/nebula_terminal/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "nebula_terminal"
-version = "1.7.0"
+version = "1.8.0"
license = "GPL-3.0-or-later"
description = "Nebula terminal emulation core"
readme = "../README.md"
diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1
index 2d3040c..55112b3 100644
--- a/scripts/build-installer.ps1
+++ b/scripts/build-installer.ps1
@@ -85,28 +85,7 @@ if (-not (Test-Path -LiteralPath $installerScript -PathType Leaf)) {
}
if (-not $SkipBuild) {
- Push-Location $repo
- $previousTargetDirectory = $env:CARGO_TARGET_DIR
- try {
- $env:CARGO_TARGET_DIR = $cargoTargetRoot
- $workspaceArgs = @('build', '--workspace', '--exclude', 'nebula', '--locked')
- $gpuiArgs = @('build', '-p', 'nebula', '--bin', 'pebrel', '--features', 'gpui-shell', '--locked')
- if ($Configuration -eq 'release') {
- $workspaceArgs += '--release'
- $gpuiArgs += '--release'
- }
- & cargo @workspaceArgs
- if ($LASTEXITCODE -ne 0) {
- throw "Cargo workspace build failed with exit code $LASTEXITCODE"
- }
- & cargo @gpuiArgs
- if ($LASTEXITCODE -ne 0) {
- throw "Cargo gpui-shell build failed with exit code $LASTEXITCODE"
- }
- } finally {
- $env:CARGO_TARGET_DIR = $previousTargetDirectory
- Pop-Location
- }
+ & (Join-Path $PSScriptRoot 'build-windows-product.ps1') -Configuration $Configuration -TargetDirectory $cargoTargetRoot
}
$missing = @($requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) })
diff --git a/scripts/build-windows-product.ps1 b/scripts/build-windows-product.ps1
new file mode 100644
index 0000000..89367e2
--- /dev/null
+++ b/scripts/build-windows-product.ps1
@@ -0,0 +1,36 @@
+[CmdletBinding()]
+param(
+ [ValidateSet('debug', 'release')]
+ [string] $Configuration = 'release',
+ [string] $TargetDirectory
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+if ([string]::IsNullOrWhiteSpace($TargetDirectory)) {
+ $TargetDirectory = Join-Path $repo 'target'
+} elseif (-not [IO.Path]::IsPathRooted($TargetDirectory)) {
+ $TargetDirectory = Join-Path $repo $TargetDirectory
+}
+$previousTargetDirectory = $env:CARGO_TARGET_DIR
+Push-Location $repo
+try {
+ $env:CARGO_TARGET_DIR = [IO.Path]::GetFullPath($TargetDirectory)
+ # Keep the feature graph identical for the initial build, ZIP and installer.
+ # The acceptance lab is tested by CI but does not supply a packaged binary.
+ $arguments = @(
+ 'build', '--locked', '--timings',
+ '-p', 'nebula', '--bin', 'pebrel',
+ '-p', 'nebula_hook', '--bin', 'pebrel-hook',
+ '--features', 'nebula/gpui-shell'
+ )
+ if ($Configuration -eq 'release') { $arguments += '--release' }
+ & cargo @arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "Cargo Windows product build failed with exit code $LASTEXITCODE"
+ }
+} finally {
+ $env:CARGO_TARGET_DIR = $previousTargetDirectory
+ Pop-Location
+}
diff --git a/scripts/ci_native_tests.py b/scripts/ci_native_tests.py
new file mode 100644
index 0000000..e68b682
--- /dev/null
+++ b/scripts/ci_native_tests.py
@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""Run the complete native suite with one Rust workspace feature graph."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def native_commands() -> list[list[str]]:
+ profile = ["--config", ".github/ci-profile.toml", "--profile", "ci"]
+ return [
+ [sys.executable, "-m", "unittest", "discover", "-s", "scripts/tests", "-v"],
+ [sys.executable, "-m", "unittest", "discover", "-s", "scripts/conformance/tests", "-v"],
+ [
+ "cargo", "test", "--locked", *profile, "--workspace",
+ "--features", "nebula/gpui-test-support", "--timings",
+ ],
+ # Link the test graph first: check can reuse compatible compiled
+ # dependencies, while metadata-only check output cannot link the tests.
+ # Keep the actual production feature graph independently checked.
+ [
+ "cargo", "check", "--locked", *profile, "-p", "nebula", "--bin", "pebrel",
+ "--features", "gpui-shell", "--timings",
+ ],
+ ]
+
+
+def main() -> int:
+ for command in native_commands():
+ print("Running:", " ".join(command), flush=True)
+ subprocess.run(command, check=True)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/conformance/cases/boot.py b/scripts/conformance/cases/boot.py
index cccee9f..0a5376a 100644
--- a/scripts/conformance/cases/boot.py
+++ b/scripts/conformance/cases/boot.py
@@ -1,9 +1,12 @@
from __future__ import annotations
+import time
+
from conformance.harness import ConformanceContext, PROTOCOL_NAME, PROTOCOL_VERSION, path_shape, require
def run(ctx: ConformanceContext) -> dict[str, object]:
+ started = time.monotonic()
snapshot = ctx.snapshot()
ctx.refresh_targets(snapshot)
windows = snapshot.get("windows") or []
@@ -47,6 +50,14 @@ def run(ctx: ConformanceContext) -> dict[str, object]:
cwd = pane.get("cwd", "")
require(path_shape(cwd) != "relative", f"pane cwd is not absolute: {cwd!r}")
+ # Runtime discovery and a child PID can exist before a cold shell emits any
+ # text. Finish the bounded boot phase before the echo case submits input;
+ # its own output deadline and single submission remain unchanged.
+ ctx.poll(
+ ctx.read, lambda read: bool(read["text"].strip()),
+ "default shell did not render initial output", ctx.startup_timeout,
+ )
+
return {
"window_count": 1,
"default_tab_present": True,
@@ -54,5 +65,5 @@ def run(ctx: ConformanceContext) -> dict[str, object]:
"process_tree_available": True,
"shell_category": shell,
"cwd_shape": path_shape(cwd),
- "startup_ms": ctx.startup_ms,
+ "startup_ms": ctx.startup_ms + round((time.monotonic() - started) * 1000),
}
diff --git a/scripts/conformance/harness.py b/scripts/conformance/harness.py
index d6b0180..92da174 100644
--- a/scripts/conformance/harness.py
+++ b/scripts/conformance/harness.py
@@ -337,13 +337,13 @@ class _WindowsJob:
self._process_handles: list[int] = []
self._handle = self._api.CreateJobObjectW(None, None)
if not self._handle:
- raise ctypes.WinError(ctypes.get_last_error())
+ raise ctypes.WinError(ctypes.get_last_error(), "CreateJobObjectW failed")
limits = ExtendedLimits()
limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE
if not self._api.SetInformationJobObject(
self._handle, 9, ctypes.byref(limits), ctypes.sizeof(limits)
):
- error = ctypes.WinError(ctypes.get_last_error())
+ error = ctypes.WinError(ctypes.get_last_error(), "SetInformationJobObject failed")
self.close()
raise error
@@ -351,7 +351,7 @@ class _WindowsJob:
# CPython retains the native process handle on Windows. Using that
# handle avoids opening a PID that could have been recycled.
if not self._api.AssignProcessToJobObject(self._handle, int(process._handle)):
- raise ctypes.WinError(ctypes.get_last_error())
+ raise ctypes.WinError(ctypes.get_last_error(), "AssignProcessToJobObject failed")
def terminate(self, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py
index 950c0f0..0bddd08 100644
--- a/scripts/conformance/tests/test_harness.py
+++ b/scripts/conformance/tests/test_harness.py
@@ -8,7 +8,7 @@ import sys
import tempfile
import unittest
from types import SimpleNamespace
-from unittest.mock import patch
+from unittest.mock import Mock, patch
import zipfile
from pathlib import Path
@@ -31,6 +31,47 @@ from conformance.harness import ( # noqa: E402
from conformance.macos_launch import capture_screenshot
+class BootReadinessTests(unittest.TestCase):
+ def context(self, outputs):
+ from conformance.harness import ConformanceContext, PROTOCOL_NAME, PROTOCOL_VERSION
+
+ pane = {"id": 1, "cwd": r"C:\fixture"}
+ tab = {"panes": [pane]}
+ snapshot = {"protocol_version": PROTOCOL_VERSION, "windows": [{"tabs": [tab]}]}
+ context = SimpleNamespace(
+ snapshot=lambda: snapshot, refresh_targets=lambda value: None,
+ window_id=1, pane_id=1, startup_ms=10, startup_timeout=4,
+ description={"protocol": PROTOCOL_NAME, "protocol_version": PROTOCOL_VERSION,
+ "capabilities": ["runtime.snapshot", "window.close", "tab.new",
+ "tab.rename", "pane.split", "pane.resize", "pane.prompt",
+ "pane.paste", "pane.read", "pane.procs", "pane.send_key"]},
+ detect_shell=lambda: "powershell", api=lambda *args: {"processes": [{"pid": 42}]},
+ tab_for_pane=lambda *args: tab, read=Mock(side_effect=outputs),
+ )
+ context.poll = ConformanceContext.poll.__get__(context)
+ return context
+
+ def test_runtime_discovery_alone_does_not_finish_cold_shell_boot(self):
+ from conformance.cases.boot import run
+
+ context = self.context([{"text": ""}, {"text": " \n"}, {"text": "PS C:\\fixture> "}])
+ with patch("time.sleep"), patch("time.monotonic", side_effect=range(20)):
+ result = run(context)
+ self.assertEqual(context.read.call_count, 3)
+ self.assertTrue(result["default_tab_present"])
+ self.assertGreater(result["startup_ms"], context.startup_ms)
+
+ def test_silent_shell_fails_boot_instead_of_being_marked_ready(self):
+ from conformance.cases.boot import run
+
+ context = self.context(None)
+ context.read.side_effect = None
+ context.read.return_value = {"text": ""}
+ with patch("time.sleep"), patch("time.monotonic", side_effect=range(20)):
+ with self.assertRaisesRegex(ConformanceError, "did not render initial output"):
+ run(context)
+
+
class NormalizationTests(unittest.TestCase):
def test_normalize_path_handles_drive_and_posix_paths(self) -> None:
self.assertEqual(normalize_path(r"C:\Users\Test\nebula"), "/c/Users/Test/nebula")
@@ -139,7 +180,7 @@ class ArchiveSafetyTests(unittest.TestCase):
def test_macos_bundle_resolves_declared_executable_and_rejects_other_paths(self) -> None:
with tempfile.TemporaryDirectory() as directory:
- root = Path(directory) / "Pebrel Preview.app"
+ root = Path(directory).resolve() / "Pebrel Preview.app"
binary = root / "Contents" / "MacOS" / "pebrel"
binary.parent.mkdir(parents=True)
binary.write_bytes(b"fixture")
diff --git a/scripts/conformance/tests/test_posix_lifecycle.py b/scripts/conformance/tests/test_posix_lifecycle.py
index 8cd3cb8..817ed50 100644
--- a/scripts/conformance/tests/test_posix_lifecycle.py
+++ b/scripts/conformance/tests/test_posix_lifecycle.py
@@ -29,7 +29,11 @@ role = sys.argv[2]
if role != 'grandchild':
next_role = 'child' if role == 'parent' else 'grandchild'
subprocess.Popen([sys.executable, '-I', '-S', __file__, str(root), next_role])
-(root / (role + '.pid')).write_text(str(os.getpid()))
+# Publish the PID only after the contents are complete. The parent polls for
+# file existence, so creating the final path first exposes an empty file.
+pid_path = root / (role + '.pid.tmp')
+pid_path.write_text(str(os.getpid()))
+pid_path.replace(root / (role + '.pid'))
while True:
if role == 'parent' and (root / 'exit').exists():
code = root / 'exit-code'
diff --git a/scripts/conformance/tests/test_scrollback.py b/scripts/conformance/tests/test_scrollback.py
index b43f5df..4ef8e1f 100644
--- a/scripts/conformance/tests/test_scrollback.py
+++ b/scripts/conformance/tests/test_scrollback.py
@@ -93,7 +93,8 @@ class ScrollbackCompletionTests(unittest.TestCase):
capture_output=True,
text=True,
encoding="utf-8",
- timeout=10,
+ # Include cold shell startup on shared CI runners; this checks output, not latency.
+ timeout=60,
check=True,
)
self.assertEqual(
diff --git a/scripts/conformance/tests/test_windows_standard_user.py b/scripts/conformance/tests/test_windows_standard_user.py
new file mode 100644
index 0000000..2dc46cc
--- /dev/null
+++ b/scripts/conformance/tests/test_windows_standard_user.py
@@ -0,0 +1,54 @@
+import os
+import sys
+import unittest
+
+from scripts.conformance.windows_standard_user import WindowsTokens, run_python
+
+
+@unittest.skipUnless(os.name == "nt", "requires native Windows process tokens")
+class StandardUserLaunchTests(unittest.TestCase):
+ def test_restricted_token_can_launch_even_when_parent_is_already_ordinary(self):
+ api = WindowsTokens()
+ token, restricted = api.current_token(0x008B), None
+ try:
+ restricted = api.restrict(token)
+ self.assertFalse(api.elevated(restricted))
+ self.assertEqual(api.run(restricted, [sys.executable, "-c", "raise SystemExit(19)"]), 19)
+ finally:
+ if restricted:
+ api.kernel.CloseHandle(restricted)
+ api.kernel.CloseHandle(token)
+
+ def test_child_is_unelevated_and_preserves_arguments_and_failure_code(self):
+ script = """
+import sys
+import subprocess
+from scripts.conformance.windows_standard_user import WindowsTokens
+api = WindowsTokens()
+token = api.current_token()
+try:
+ assert not api.elevated(token), 'child retained administrator elevation'
+finally:
+ api.kernel.CloseHandle(token)
+assert sys.argv[1:] == ['path with spaces\\\\', '中文', 'literal & symbol']
+child = subprocess.run([sys.executable, '-c', 'print("pipe output"); raise SystemExit(73)'],
+ input='pipe input', capture_output=True, text=True)
+assert child.stdout.strip() == 'pipe output'
+raise SystemExit(child.returncode)
+"""
+ self.assertEqual(run_python(["-c", script, "path with spaces\\", "中文",
+ "literal & symbol"]), 73)
+
+ def test_successful_child_does_not_change_parent_elevation(self):
+ api = WindowsTokens()
+ token = api.current_token()
+ try:
+ before = api.elevated(token)
+ self.assertEqual(run_python(["-c", "raise SystemExit(0)"]), 0)
+ self.assertEqual(api.elevated(token), before)
+ finally:
+ api.kernel.CloseHandle(token)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/conformance/windows_standard_user.py b/scripts/conformance/windows_standard_user.py
new file mode 100644
index 0000000..f9eff4f
--- /dev/null
+++ b/scripts/conformance/windows_standard_user.py
@@ -0,0 +1,200 @@
+"""Run native conformance as an ordinary user on elevated Windows runners.
+
+Use a restricted copy of the current user token, retaining the same desktop,
+profile and environment. Product administrator isolation stays enabled.
+"""
+
+from __future__ import annotations
+
+import ctypes
+from ctypes import wintypes as wt
+import os
+import subprocess
+import sys
+
+
+class SidAndAttributes(ctypes.Structure):
+ _fields_ = [("Sid", wt.LPVOID), ("Attributes", wt.DWORD)]
+
+
+class StartupInfo(ctypes.Structure):
+ _fields_ = [
+ ("cb", wt.DWORD), ("lpReserved", wt.LPWSTR), ("lpDesktop", wt.LPWSTR),
+ ("lpTitle", wt.LPWSTR), ("dwX", wt.DWORD), ("dwY", wt.DWORD),
+ ("dwXSize", wt.DWORD), ("dwYSize", wt.DWORD), ("dwXCountChars", wt.DWORD),
+ ("dwYCountChars", wt.DWORD), ("dwFillAttribute", wt.DWORD),
+ ("dwFlags", wt.DWORD), ("wShowWindow", wt.WORD), ("cbReserved2", wt.WORD),
+ ("lpReserved2", ctypes.POINTER(wt.BYTE)), ("hStdInput", wt.HANDLE),
+ ("hStdOutput", wt.HANDLE), ("hStdError", wt.HANDLE),
+ ]
+
+
+class ProcessInfo(ctypes.Structure):
+ _fields_ = [("hProcess", wt.HANDLE), ("hThread", wt.HANDLE),
+ ("dwProcessId", wt.DWORD), ("dwThreadId", wt.DWORD)]
+
+
+class WindowsTokens:
+ def __init__(self):
+ if os.name != "nt":
+ raise RuntimeError("the ordinary-user launcher requires Windows")
+ self.kernel = ctypes.WinDLL("kernel32", use_last_error=True)
+ self.security = ctypes.WinDLL("advapi32", use_last_error=True)
+ signatures = [
+ (self.kernel, "GetCurrentProcess", wt.HANDLE, []),
+ (self.kernel, "GetStdHandle", wt.HANDLE, [wt.DWORD]),
+ (self.kernel, "CloseHandle", wt.BOOL, [wt.HANDLE]),
+ (self.kernel, "LocalFree", wt.HANDLE, [wt.HANDLE]),
+ (self.kernel, "WaitForSingleObject", wt.DWORD, [wt.HANDLE, wt.DWORD]),
+ (self.kernel, "GetExitCodeProcess", wt.BOOL,
+ [wt.HANDLE, ctypes.POINTER(wt.DWORD)]),
+ (self.security, "OpenProcessToken", wt.BOOL,
+ [wt.HANDLE, wt.DWORD, ctypes.POINTER(wt.HANDLE)]),
+ (self.security, "GetTokenInformation", wt.BOOL,
+ [wt.HANDLE, wt.DWORD, wt.LPVOID, wt.DWORD, ctypes.POINTER(wt.DWORD)]),
+ (self.security, "SetTokenInformation", wt.BOOL,
+ [wt.HANDLE, wt.DWORD, wt.LPVOID, wt.DWORD]),
+ (self.security, "GetLengthSid", wt.DWORD, [wt.LPVOID]),
+ (self.security, "ConvertStringSidToSidW", wt.BOOL,
+ [wt.LPCWSTR, ctypes.POINTER(wt.LPVOID)]),
+ (self.security, "ConvertSidToStringSidW", wt.BOOL,
+ [wt.LPVOID, ctypes.POINTER(wt.LPWSTR)]),
+ (self.security, "ConvertStringSecurityDescriptorToSecurityDescriptorW", wt.BOOL,
+ [wt.LPCWSTR, wt.DWORD, ctypes.POINTER(wt.LPVOID), ctypes.POINTER(wt.DWORD)]),
+ (self.security, "GetSecurityDescriptorDacl", wt.BOOL,
+ [wt.LPVOID, ctypes.POINTER(wt.BOOL), ctypes.POINTER(wt.LPVOID),
+ ctypes.POINTER(wt.BOOL)]),
+ (self.security, "CreateRestrictedToken", wt.BOOL,
+ [wt.HANDLE, wt.DWORD, wt.DWORD, wt.LPVOID, wt.DWORD, wt.LPVOID,
+ wt.DWORD, wt.LPVOID, ctypes.POINTER(wt.HANDLE)]),
+ (self.security, "CreateProcessAsUserW", wt.BOOL,
+ [wt.HANDLE, wt.LPCWSTR, wt.LPWSTR, wt.LPVOID, wt.LPVOID, wt.BOOL,
+ wt.DWORD, wt.LPVOID, wt.LPCWSTR, ctypes.POINTER(StartupInfo),
+ ctypes.POINTER(ProcessInfo)]),
+ ]
+ for library, name, result, arguments in signatures:
+ function = getattr(library, name)
+ function.restype, function.argtypes = result, arguments
+
+ @staticmethod
+ def require(success):
+ if not success:
+ raise ctypes.WinError(ctypes.get_last_error())
+
+ def current_token(self, access=0x0008):
+ token = wt.HANDLE()
+ # Elevation checks only need QUERY. In particular, a child launched from
+ # an administrator's restricted token may not reopen it for adjustment.
+ self.require(self.security.OpenProcessToken(
+ self.kernel.GetCurrentProcess(), access, ctypes.byref(token)))
+ return token
+
+ def elevated(self, token):
+ value, size = wt.DWORD(), wt.DWORD()
+ self.require(self.security.GetTokenInformation(
+ token, 20, ctypes.byref(value), ctypes.sizeof(value), ctypes.byref(size)))
+ return bool(value.value)
+
+ def restrict(self, token):
+ restricted, sid = wt.HANDLE(), wt.LPVOID()
+ # LUA_TOKEN removes administrator membership and elevated privileges.
+ self.require(self.security.CreateRestrictedToken(
+ token, 0x4, 0, None, 0, None, 0, None, ctypes.byref(restricted)))
+ try:
+ self.require(self.security.ConvertStringSidToSidW(
+ "S-1-16-8192", ctypes.byref(sid))) # Medium integrity.
+ label = SidAndAttributes(sid, 0x20) # SE_GROUP_INTEGRITY.
+ self.require(self.security.SetTokenInformation(
+ restricted, 25, ctypes.byref(label),
+ ctypes.sizeof(label) + self.security.GetLengthSid(sid)))
+ self.set_user_default_dacl(restricted)
+ if self.elevated(restricted):
+ raise RuntimeError("restricted token is still elevated; refusing conformance")
+ return restricted
+ except BaseException:
+ self.kernel.CloseHandle(restricted)
+ raise
+ finally:
+ if sid:
+ self.kernel.LocalFree(sid)
+
+ def set_user_default_dacl(self, token):
+ # Elevated runners can default new objects to Administrators/SYSTEM.
+ # After filtering administrators, anonymous pipes then deny their own
+ # creator access. Grant the same user and SYSTEM access on new objects
+ # created with this temporary token; no existing file ACL is changed.
+ size = wt.DWORD()
+ self.security.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
+ if not size.value:
+ raise ctypes.WinError(ctypes.get_last_error())
+ user = ctypes.create_string_buffer(size.value)
+ self.require(self.security.GetTokenInformation(
+ token, 1, user, size, ctypes.byref(size)))
+ sid, descriptor = wt.LPWSTR(), wt.LPVOID()
+ try:
+ self.require(self.security.ConvertSidToStringSidW(
+ SidAndAttributes.from_buffer(user).Sid, ctypes.byref(sid)))
+ self.require(self.security.ConvertStringSecurityDescriptorToSecurityDescriptorW(
+ "D:(A;;GA;;;SY)(A;;GA;;;" + sid.value + ")", 1,
+ ctypes.byref(descriptor), None))
+ present, defaulted, acl = wt.BOOL(), wt.BOOL(), wt.LPVOID()
+ self.require(self.security.GetSecurityDescriptorDacl(
+ descriptor, ctypes.byref(present), ctypes.byref(acl), ctypes.byref(defaulted)))
+ if not present.value or not acl:
+ raise RuntimeError("ordinary token requires an explicit user DACL")
+ self.require(self.security.SetTokenInformation(
+ token, 6, ctypes.byref(acl), ctypes.sizeof(acl))) # TokenDefaultDacl.
+ finally:
+ if descriptor:
+ self.kernel.LocalFree(descriptor)
+ if sid:
+ self.kernel.LocalFree(ctypes.cast(sid, wt.HANDLE))
+
+ def run(self, token, arguments):
+ command = ctypes.create_unicode_buffer(subprocess.list2cmdline(arguments))
+ startup = StartupInfo()
+ startup.cb, startup.dwFlags = ctypes.sizeof(startup), 0x100
+ startup.hStdInput = self.kernel.GetStdHandle(-10)
+ startup.hStdOutput = self.kernel.GetStdHandle(-11)
+ startup.hStdError = self.kernel.GetStdHandle(-12)
+ process = ProcessInfo()
+ self.require(self.security.CreateProcessAsUserW(
+ token, arguments[0], command, None, None, True, 0, None, os.getcwd(),
+ ctypes.byref(startup), ctypes.byref(process)))
+ try:
+ if self.kernel.WaitForSingleObject(process.hProcess, 0xFFFFFFFF) != 0:
+ raise ctypes.WinError(ctypes.get_last_error())
+ code = wt.DWORD()
+ self.require(self.kernel.GetExitCodeProcess(process.hProcess, ctypes.byref(code)))
+ return code.value
+ finally:
+ self.kernel.CloseHandle(process.hThread)
+ self.kernel.CloseHandle(process.hProcess)
+
+
+def run_python(arguments):
+ api = WindowsTokens()
+ token, restricted = api.current_token(), None
+ try:
+ command = [sys.executable, *arguments]
+ if not api.elevated(token):
+ return subprocess.run(command, check=False).returncode
+ # QUERY | DUPLICATE | ASSIGN_PRIMARY | ADJUST_DEFAULT. Only the elevated
+ # parent needs these rights, to create and lower its restricted copy.
+ source = api.current_token(0x008B)
+ try:
+ restricted = api.restrict(source)
+ finally:
+ api.kernel.CloseHandle(source)
+ print("Running native conformance with a verified non-elevated token", flush=True)
+ return api.run(restricted, command)
+ finally:
+ if restricted:
+ api.kernel.CloseHandle(restricted)
+ api.kernel.CloseHandle(token)
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 2:
+ raise SystemExit("usage: windows_standard_user.py [arguments...]")
+ raise SystemExit(run_python(sys.argv[1:]))
diff --git a/scripts/installer.iss b/scripts/installer.iss
index f8a9fbb..6fc0299 100644
--- a/scripts/installer.iss
+++ b/scripts/installer.iss
@@ -1,9 +1,9 @@
-#ifndef AppVersion
- #define AppVersion "1.7.0"
+#ifndef AppVersion
+ #define AppVersion "1.8.0"
#endif
#ifndef NumericVersion
- #define NumericVersion "1.7.0.0"
+ #define NumericVersion "1.8.0.0"
#endif
#ifndef Configuration
diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1
index 400d7b8..671146d 100644
--- a/scripts/package-release.ps1
+++ b/scripts/package-release.ps1
@@ -144,33 +144,7 @@ function Assert-FreshBinaries {
}
if (-not $SkipBuild) {
- Push-Location $repo
- $previousTargetDirectory = $env:CARGO_TARGET_DIR
- try {
- $env:CARGO_TARGET_DIR = $cargoTargetRoot
- # Never build nebula without gpui-shell first: a workspace default
- # binary overwrites the product exe with the legacy winit shell.
- # Exclude nebula from the workspace build, then link GPUI last.
- if ($Configuration -eq 'release') {
- & cargo build --workspace --release --exclude nebula --locked
- if ($LASTEXITCODE -ne 0) {
- throw "Cargo workspace build failed with exit code $LASTEXITCODE"
- }
- & cargo build -p nebula --bin pebrel --release --features gpui-shell --locked
- } else {
- & cargo build --workspace --exclude nebula --locked
- if ($LASTEXITCODE -ne 0) {
- throw "Cargo workspace build failed with exit code $LASTEXITCODE"
- }
- & cargo build -p nebula --bin pebrel --features gpui-shell --locked
- }
- if ($LASTEXITCODE -ne 0) {
- throw "Cargo gpui-shell build failed with exit code $LASTEXITCODE"
- }
- } finally {
- $env:CARGO_TARGET_DIR = $previousTargetDirectory
- Pop-Location
- }
+ & (Join-Path $PSScriptRoot 'build-windows-product.ps1') -Configuration $Configuration -TargetDirectory $cargoTargetRoot
}
$missing = @($manifest.GetEnumerator() | Where-Object {
diff --git a/scripts/tests/build-windows-product.tests.ps1 b/scripts/tests/build-windows-product.tests.ps1
new file mode 100644
index 0000000..d236a97
--- /dev/null
+++ b/scripts/tests/build-windows-product.tests.ps1
@@ -0,0 +1,44 @@
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+$builder = Join-Path $PSScriptRoot '../build-windows-product.ps1'
+$originalTarget = $env:CARGO_TARGET_DIR
+$originalDirectory = (Get-Location).Path
+$global:ProductBuildCalls = @()
+$global:ProductBuildExit = 0
+function global:cargo {
+ $global:ProductBuildCalls += ,@($args)
+ $global:LASTEXITCODE = $global:ProductBuildExit
+}
+try {
+ $env:CARGO_TARGET_DIR = 'preserve-caller-target'
+ & $builder -Configuration release -TargetDirectory target
+ if ($global:ProductBuildCalls.Count -ne 1) { throw 'Expected one product build' }
+ $arguments = $global:ProductBuildCalls[0]
+ $packages = @(); $binaries = @()
+ for ($i = 0; $i -lt $arguments.Count - 1; $i++) {
+ if ($arguments[$i] -eq '-p') { $packages += $arguments[$i + 1] }
+ if ($arguments[$i] -eq '--bin') { $binaries += $arguments[$i + 1] }
+ }
+ if (($packages -join ',') -ne 'nebula,nebula_hook') { throw 'Unexpected package selection' }
+ if (($binaries -join ',') -ne 'pebrel,pebrel-hook') { throw 'Unexpected product binaries' }
+ foreach ($required in @('--locked', '--release', '--features', 'nebula/gpui-shell')) {
+ if ($required -notin $arguments) { throw "Missing product build argument: $required" }
+ }
+ if ('--workspace' -in $arguments) { throw 'Packaging must not build the acceptance lab' }
+ if ($env:CARGO_TARGET_DIR -ne 'preserve-caller-target') { throw 'Caller target changed' }
+ & $builder -Configuration debug -TargetDirectory target
+ if ('--release' -in $global:ProductBuildCalls[1]) { throw 'Debug build selected release' }
+ $global:ProductBuildExit = 27
+ $failed = $false
+ try { & $builder -Configuration release -TargetDirectory target } catch {
+ $failed = $_.Exception.Message -match 'exit code 27'
+ }
+ if (-not $failed) { throw 'Cargo failure must fail packaging' }
+ if ($env:CARGO_TARGET_DIR -ne 'preserve-caller-target') { throw 'Failure changed caller target' }
+ if ((Get-Location).Path -ne $originalDirectory) { throw 'Failure changed caller directory' }
+ Write-Output 'build-windows-product.tests.ps1: PASS'
+} finally {
+ $env:CARGO_TARGET_DIR = $originalTarget
+ Remove-Item Function:\cargo
+ Remove-Variable ProductBuildCalls,ProductBuildExit -Scope Global
+}
diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1
index 6226ccb..615aff3 100644
--- a/scripts/tests/package-release.tests.ps1
+++ b/scripts/tests/package-release.tests.ps1
@@ -38,15 +38,10 @@ try {
}
$scriptBody = Get-Content -LiteralPath $packageScript -Raw -Encoding UTF8
- if ($scriptBody -notmatch '--features gpui-shell') {
- throw 'Release packaging must rebuild Pebrel with --features gpui-shell so the zip ships the GPUI shell.'
- }
- if ($scriptBody -notmatch '--bin pebrel') {
- throw 'Release packaging must build the Pebrel product executable.'
- }
- if ($scriptBody -notmatch '--exclude nebula') {
- throw 'Release packaging must exclude nebula from the workspace build so the default-feature binary cannot overwrite the GPUI shell.'
+ if ($scriptBody -notmatch 'build-windows-product.ps1') {
+ throw 'Release packaging must use the shared explicit product build.'
}
+ & (Join-Path $PSScriptRoot 'build-windows-product.tests.ps1')
if ($scriptBody -notmatch 'Assert-FreshBinaries') {
throw 'Release packaging must refuse stale binaries (freshness guard missing).'
diff --git a/scripts/tests/test_architecture_budgets.py b/scripts/tests/test_architecture_budgets.py
index bbfa0f6..91cd27a 100644
--- a/scripts/tests/test_architecture_budgets.py
+++ b/scripts/tests/test_architecture_budgets.py
@@ -13,7 +13,7 @@ class LineBudgetTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
- self.root = Path(self.temporary.name)
+ self.root = Path(self.temporary.name).resolve()
(self.root / "app/src").mkdir(parents=True)
def write(self, name, count):
diff --git a/scripts/tests/test_ci_cache.py b/scripts/tests/test_ci_cache.py
new file mode 100644
index 0000000..f3e51bc
--- /dev/null
+++ b/scripts/tests/test_ci_cache.py
@@ -0,0 +1,81 @@
+"""Exercise the cache identity code used by the composite action itself."""
+
+import os
+from pathlib import Path
+import re
+import tempfile
+import textwrap
+import unittest
+from unittest.mock import patch
+
+
+ACTION = Path(__file__).resolve().parents[2] / ".github/actions/rust-cache/action.yml"
+
+
+class CacheIdentityTests(unittest.TestCase):
+ def identity(self, *, compiler="rustc 1.97.1", sdk="15.0", **changes):
+ source = ACTION.read_text(encoding="utf-8")
+ body = re.search(r"python - <<'PY'\n(.*?)\n PY", source, re.S)
+ self.assertIsNotNone(body, "cache identity must remain executable from this action")
+ code = textwrap.dedent(body.group(1))
+ with tempfile.TemporaryDirectory() as temporary:
+ output = Path(temporary) / "outputs"
+ environment = {
+ "CACHE_OS": "Linux", "CACHE_ARCH": "X64", "CACHE_WORKLOAD": "ci-product",
+ "CACHE_MANIFESTS": "pinned-manifests", "GITHUB_OUTPUT": str(output),
+ "CACHE_REVISION": "source-revision",
+ "CARGO_HOME": str(Path(temporary) / "cargo"), **changes,
+ }
+
+ def command(arguments, **kwargs):
+ if arguments == ["rustc", "-vV"]:
+ return compiler
+ self.assertEqual(arguments, ["xcrun", "--sdk", "macosx", "--show-sdk-version"])
+ return sdk
+
+ with patch.dict(os.environ, environment, clear=True), patch("subprocess.check_output", side_effect=command):
+ exec(compile(code, str(ACTION), "exec"), {})
+ return dict(line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines())
+
+ def test_compiler_flags_and_workload_isolate_compiled_targets(self):
+ baseline = self.identity()["key"]
+ for change in (
+ {"compiler": "rustc 1.98.0"}, {"RUSTFLAGS": "-C opt-level=1"},
+ {"CARGO_PROFILE_RELEASE_LTO": "false"}, {"CACHE_WORKLOAD": "release"},
+ {"CACHE_ARCH": "ARM64"}, {"ImageOS": "a-different-runner-image"},
+ ):
+ with self.subTest(change=change):
+ self.assertNotEqual(baseline, self.identity(**change)["key"])
+
+ def test_macos_sdk_and_deployment_floor_are_part_of_identity(self):
+ baseline = self.identity(CACHE_OS="macOS", sdk="15.0")["key"]
+ self.assertNotEqual(baseline, self.identity(CACHE_OS="macOS", sdk="16.0")["key"])
+ self.assertNotEqual(baseline, self.identity(CACHE_OS="macOS", MACOSX_DEPLOYMENT_TARGET="13.0")["key"])
+
+ def test_dependency_change_can_restore_compatible_previous_targets(self):
+ before = self.identity(CACHE_MANIFESTS="before")
+ after = self.identity(CACHE_MANIFESTS="after")
+ self.assertNotEqual(before["key"], after["key"])
+ self.assertEqual(before["restore-key"], after["restore-key"])
+ self.assertTrue(after["key"].startswith(after["restore-key"]))
+
+ def test_source_only_revision_and_package_labels_do_not_bust_dependencies(self):
+ before = self.identity(CACHE_REVISION="one", PREVIEW_ID="first")
+ after = self.identity(CACHE_REVISION="two", PREVIEW_ID="second")
+ self.assertNotEqual(before["key"], after["key"])
+ self.assertEqual(before["manifest-key"], after["manifest-key"])
+ self.assertEqual(before["restore-key"], after["restore-key"])
+ self.assertEqual(before["key"], self.identity(CACHE_REVISION="one", PREVIEW_ID="third")["key"])
+
+ @unittest.skipUnless(os.name == "nt", "Windows cache migration")
+ def test_windows_migration_preserves_compiler_and_workload_boundaries(self):
+ before = self.identity()
+ after = self.identity(ImageOS="win22")
+ self.assertEqual(before["restore-key"], after["migration-key"])
+ for change in ({"compiler": "rustc 1.98.0"}, {"CACHE_WORKLOAD": "release"},
+ {"RUSTFLAGS": "-C opt-level=1"}):
+ self.assertNotEqual(after["migration-key"], self.identity(**change)["migration-key"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/tests/test_ci_native_tests.py b/scripts/tests/test_ci_native_tests.py
new file mode 100644
index 0000000..a9490da
--- /dev/null
+++ b/scripts/tests/test_ci_native_tests.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import subprocess
+from pathlib import Path
+import tomllib
+import unittest
+from unittest.mock import patch
+
+from scripts.ci_native_tests import main, native_commands
+
+
+class NativeSuiteTests(unittest.TestCase):
+ def test_full_workspace_and_interactions_share_one_unfiltered_invocation(self):
+ rust = [command for command in native_commands() if command[:2] == ["cargo", "test"]]
+ self.assertEqual(len(rust), 1)
+ command = rust[0]
+ self.assertEqual(command[1], "test")
+ self.assertIn("--locked", command)
+ self.assertIn("--workspace", command)
+ self.assertEqual(command[command.index("--features") + 1], "nebula/gpui-test-support")
+ self.assertNotIn("--exclude", command)
+ self.assertNotIn("--lib", command)
+ self.assertNotIn("--skip", command)
+ self.assertNotIn("--", command)
+
+ def test_actual_product_feature_graph_is_also_checked(self):
+ checks = [command for command in native_commands() if command[:2] == ["cargo", "check"]]
+ self.assertEqual(len(checks), 1)
+ command = checks[0]
+ self.assertEqual(command[command.index("--features") + 1], "gpui-shell")
+ self.assertEqual(command[command.index("--bin") + 1], "pebrel")
+
+ def test_fast_test_profile_preserves_runtime_checks_and_resets_named_overrides(self):
+ root = Path(__file__).resolve().parents[2]
+ config = tomllib.loads((root / ".github/ci-profile.toml").read_text())
+ workspace = tomllib.loads((root / "Cargo.toml").read_text())
+ profile = config["profile"]["ci"]
+ self.assertEqual(profile["inherits"], "dev")
+ self.assertTrue(profile["debug-assertions"])
+ self.assertTrue(profile["overflow-checks"])
+ for package in workspace["profile"]["dev"]["package"]:
+ self.assertEqual(profile["package"][package]["opt-level"], 0)
+ self.assertNotIn("release", config["profile"])
+
+ def test_both_python_test_roots_are_discovered(self):
+ suites = [command for command in native_commands() if "unittest" in command]
+ self.assertEqual(
+ {command[command.index("-s") + 1] for command in suites},
+ {"scripts/tests", "scripts/conformance/tests"},
+ )
+ self.assertTrue(all("discover" in command for command in suites))
+
+ def test_success_runs_every_command_and_checks_exit_codes(self):
+ with patch("scripts.ci_native_tests.subprocess.run") as run:
+ self.assertEqual(main(), 0)
+ self.assertEqual(run.call_count, len(native_commands()))
+ self.assertTrue(all(call.kwargs["check"] for call in run.call_args_list))
+
+ def test_failure_stops_the_suite_and_is_not_reported_as_success(self):
+ with patch("scripts.ci_native_tests.subprocess.run") as run:
+ run.side_effect = subprocess.CalledProcessError(7, "test")
+ with self.assertRaises(subprocess.CalledProcessError):
+ main()
+ self.assertEqual(run.call_count, 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py
index e73dd17..0163fcf 100644
--- a/scripts/tests/test_stable_release.py
+++ b/scripts/tests/test_stable_release.py
@@ -68,21 +68,23 @@ def notes(checksum_placeholder: bool = True) -> str:
class StableReleaseTests(unittest.TestCase):
- def test_stable_workflow_runs_gpui_interaction_tests_on_every_platform(self) -> None:
+ def test_stable_workflow_requires_full_native_tests_before_aggregation(self) -> None:
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8")
- command = "cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::"
- self.assertEqual(workflow.count(command), 3)
- windows_step = workflow.split(" - name: Test workspace and native harness\n", 1)[1]
- windows_step = windows_step.split(" - name:", 1)[0]
- self.assertIn("shell: pwsh", windows_step)
- lines = [line.strip() for line in windows_step.splitlines()]
- commands = 0
- for index, line in enumerate(lines):
- if line.startswith(("cargo test ", "python -m unittest ")):
- commands += 1
- self.assertEqual(lines[index + 1], "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }")
- self.assertEqual(commands, 5)
+ native = workflow.split(" native-tests:\n", 1)[1].split("\n linux:\n", 1)[0]
+ self.assertIn("uses: ./.github/workflows/linux-lua.yml", native)
+ shared = (root / ".github/workflows/linux-lua.yml").read_text(encoding="utf-8")
+ for platform in ("ubuntu-24.04", "windows-2022", "macos-26", "macos-26-intel"):
+ self.assertIn(platform, shared)
+ self.assertIn("workflow_call:", shared)
+ self.assertIn("run: python scripts/ci_native_tests.py", shared)
+ self.assertIn("cargo check --locked --workspace --release", shared)
+ self.assertIn("tools/i18n-contract/Cargo.toml", shared)
+ self.assertNotIn("continue-on-error", shared)
+ self.assertNotIn("continue-on-error", native)
+ aggregate = workflow.split("\n aggregate:\n", 1)[1].split("\n publish:\n", 1)[0]
+ self.assertIn("needs: [prepare, native-tests, linux, macos, windows]", aggregate)
+ self.assertNotIn("always()", aggregate)
def test_native_packagers_expose_stable_channel_without_preview_id(self) -> None:
root = Path(__file__).resolve().parents[2]
diff --git a/scripts/tests/test_xplat.py b/scripts/tests/test_xplat.py
index 55720a1..cc238ad 100644
--- a/scripts/tests/test_xplat.py
+++ b/scripts/tests/test_xplat.py
@@ -18,7 +18,7 @@ class XplatIsolationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="nebula-xplat-test-")
self.addCleanup(self.temp.cleanup)
- self.root = Path(self.temp.name)
+ self.root = Path(self.temp.name).resolve()
self.repo = self.root / "repo with spaces"
self.scripts = self.repo / "scripts/xplat"
shutil.copytree(ROOT / "scripts/xplat", self.scripts)
diff --git a/tools/i18n-contract/Cargo.lock b/tools/i18n-contract/Cargo.lock
index fef745b..f055b79 100644
--- a/tools/i18n-contract/Cargo.lock
+++ b/tools/i18n-contract/Cargo.lock
@@ -16,7 +16,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nebula-settings"
-version = "1.7.0"
+version = "1.8.0"
[[package]]
name = "pebrel-i18n-contract"